From dd02b9621afbfc023b36a7c77818e568f0e427fa Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 13:05:46 +0900 Subject: [PATCH 01/32] =?UTF-8?q?fix(flutter):=20=EB=AA=A8=EC=96=91?= =?UTF-8?q?=EC=9D=B4=20=ED=8B=80=EB=A6=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=98=20=ED=86=A0=ED=81=B0=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=ED=99=95=EC=9D=B8=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=B0=BD=20=C2=B7=20=EA=B8=B0=EB=8B=A4=EB=A6=AC?= =?UTF-8?q?=EB=8D=98=20=EB=8B=A4=EC=8B=9C=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 기반 3차 확인에서 나온 낮음 넷이다. - 토큰은 실렸는데 모양이 틀린 로그인 응답은 응답을 뺀 실패로 던지고, 읽을 수 있는 토큰으로 조용히 logout 한다(비밀 요청의 실패 예외는 응답 본문도 비운다) - 확인하지 못한(unknown) 다시 확인 결과는 처음 물은 때부터 5초만 다시 쓴다 - 로그아웃 · 다른 계정 · 세션 끝 때 기다리던 다시 맞추기에 stale 로 답한다 — 새 로그인의 오류가 앞 세션의 me 를 기다리지 않게 - postAppLogout 주석을 사실대로 Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/auth_interceptor.dart | 17 ++-- flutter/lib/core/api/request_flags.dart | 15 ++-- flutter/lib/core/api/token_refresher.dart | 3 +- .../data/repositories/auth_repository.dart | 19 +++- .../providers/session_provider.dart | 43 ++++++++-- .../test/core/api/auth_interceptor_test.dart | 27 ++++++ .../features/auth/session_provider_test.dart | 86 +++++++++++++++++++ 7 files changed, 189 insertions(+), 21 deletions(-) diff --git a/flutter/lib/core/api/auth_interceptor.dart b/flutter/lib/core/api/auth_interceptor.dart index 58864ab..a48c994 100644 --- a/flutter/lib/core/api/auth_interceptor.dart +++ b/flutter/lib/core/api/auth_interceptor.dart @@ -207,13 +207,16 @@ class AuthInterceptor extends QueuedInterceptor { return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey)); } - final RestrictionCheck check = - _recentCheck(sentGeneration) ?? await _events.requestRecheck(); - _lastCheck = ( - generation: sentGeneration ?? _store.generation, - check: check, - at: _clock(), - ); + // 다시 쓴 결과는 시각을 새로 찍지 않는다 — 새로 물었을 때만 적는다(재사용 창이 끝없이 늘지 않게) + RestrictionCheck? check = _recentCheck(sentGeneration); + if (check == null) { + check = await _events.requestRecheck(); + _lastCheck = ( + generation: sentGeneration ?? _store.generation, + check: check, + at: _clock(), + ); + } return switch (check) { RestrictionCheck.renewed => _SendAgain( _retryOptions(options, kRestrictionRetriedExtraKey), diff --git a/flutter/lib/core/api/request_flags.dart b/flutter/lib/core/api/request_flags.dart index 3f933b9..fcfa6bb 100644 --- a/flutter/lib/core/api/request_flags.dart +++ b/flutter/lib/core/api/request_flags.dart @@ -61,15 +61,20 @@ bool isSilentFor(RequestOptions options, String code) { return false; } -/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 본문([kSecretBodyExtraKey]). +/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 요청([kSecretBodyExtraKey])의 요청 본문 · 응답 본문. /// /// 인증 인터셉터가 실패를 내보내기 직전에 부른다(응답의 RequestOptions 도). 같은 예외를 돌려준다. DioException withoutSecrets(DioException error) { _scrub(error.requestOptions); - final RequestOptions? responseOptions = error.response?.requestOptions; - if (responseOptions != null && - !identical(responseOptions, error.requestOptions)) { - _scrub(responseOptions); + final Response? response = error.response; + if (response != null) { + if (!identical(response.requestOptions, error.requestOptions)) { + _scrub(response.requestOptions); + } + // 비밀 요청의 응답은 토큰을 실을 수 있다(로그인 · 갱신) — 코드는 이미 `ApiException` 에 있다 + if (error.requestOptions.extra[kSecretBodyExtraKey] == true) { + response.data = null; + } } return error; } diff --git a/flutter/lib/core/api/token_refresher.dart b/flutter/lib/core/api/token_refresher.dart index 7d7e678..904d376 100644 --- a/flutter/lib/core/api/token_refresher.dart +++ b/flutter/lib/core/api/token_refresher.dart @@ -52,7 +52,8 @@ final class RefreshDiscarded extends RefreshOutcome { /// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다 /// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`). -/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다. +/// 알림은 띄우지 않는다. 실패는 `DioException` 으로 던진다 — 기본 Dio(인증 인터셉터가 있는 것)로 부르면 본문 · Bearer 를 지운 채 오고, +/// 안쪽 Dio 로 부르는 [TokenRefresher.logoutQuietly] 는 예외를 밖으로 내지 않는다. Future postAppLogout( Dio dio, { required String? refreshToken, diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 73f20f8..bc16563 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/api/server_time.dart'; import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:dio/dio.dart'; @@ -54,10 +57,22 @@ class AuthRepository { serverTime: serverTimeOf(response), ); } on FormatException { - // 아래에서 같은 실패로 던진다 + // 모양이 틀린 응답 — 토큰은 실려 왔으면 그 서버 세션을 조용히 끝낸다(쓰지 않을 세션이 남지 않게) + final AuthTokens? tokens = AuthTokens.tryParse(body['tokens']); + if (tokens != null) { + unawaited( + _refresher.logoutQuietly( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ), + ); + } } } - throw _malformed(response); + // 응답(토큰이 들었을 수 있다)을 뺀 실패로 던진다 — 요청 본문 · Bearer 도 지운다 + throw withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); } /// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기) diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index 9661534..a13f072 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -51,6 +51,9 @@ class SessionNotifier extends AsyncNotifier { Future? _rechecking; int _epoch = 0; + /// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다 + final List _pendingRechecks = []; + LoginSecret get _secret => _secretHolder ??= LoginSecret(clock: ref.read(clockProvider)); @@ -71,6 +74,7 @@ class SessionNotifier extends AsyncNotifier { ref.onDispose(() { unawaited(subscription.cancel()); _secretHolder?.clear(); + _advanceEpoch(); }); final TokenStore store = ref.watch(tokenStoreProvider); @@ -141,7 +145,7 @@ class SessionNotifier extends AsyncNotifier { await signOut(reason: SignOutReason.switchAccount); return; } - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.switchAccount), @@ -151,7 +155,7 @@ class SessionNotifier extends AsyncNotifier { /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. Future signOut({SignOutReason reason = SignOutReason.none}) async { - _epoch++; + _advanceEpoch(); _secret.clear(); // 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다) final Future clearing = _store.signOut(); @@ -177,7 +181,28 @@ class SessionNotifier extends AsyncNotifier { /// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale] /// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown] Future recheck() { - return _rechecking ??= _recheck().whenComplete(() => _rechecking = null); + final Future? running = _rechecking; + if (running != null) return running; + final Future started = _recheck(); + _rechecking = started; + started.whenComplete(() { + if (identical(_rechecking, started)) _rechecking = null; + }).ignore(); + return started; + } + + /// 시도 세대를 올린다 — 앞 세션의 진행 중인 다시 맞추기는 버리고(새 세션이 그것을 함께 기다리지 않게), + /// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게) + void _advanceEpoch() { + _epoch++; + _rechecking = null; + final List waiting = List.of( + _pendingRechecks, + ); + _pendingRechecks.clear(); + for (final SessionRecheckNeeded event in waiting) { + event.reply(RestrictionCheck.stale); + } } Future _recheck() async { @@ -334,16 +359,22 @@ class SessionNotifier extends AsyncNotifier { event.reply(RestrictionCheck.stale); return; } + _pendingRechecks.add(event); + void answer(RestrictionCheck check) { + _pendingRechecks.remove(event); + event.reply(check); + } + unawaited( recheck().then( - event.reply, + answer, onError: (Object _, StackTrace _) => - event.reply(RestrictionCheck.unknown), + answer(RestrictionCheck.unknown), ), ); case SessionEnded(): if (current is! SignedIn) return; - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.expired), diff --git a/flutter/test/core/api/auth_interceptor_test.dart b/flutter/test/core/api/auth_interceptor_test.dart index 4357da9..2b46a69 100644 --- a/flutter/test/core/api/auth_interceptor_test.dart +++ b/flutter/test/core/api/auth_interceptor_test.dart @@ -971,4 +971,31 @@ void main() { expect(api.server.received('GET', _mailState), hasLength(1)); }); }); + + group('3차 — 다시 쓰는 결과의 창', () { + test( + '확인하지 못한(unknown) 결과는 새로 물은 때부터 5초만 다시 쓴다(다시 쓴 결과로 창이 늘지 않는다)', + () async { + await api.signIn(); + api.recheckAnswer = RestrictionCheck.unknown; + api.server.on( + 'GET', + _mailState, + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 1); + + api.clock.advance(const Duration(seconds: 3)); + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 1); + + // 처음 물은 때부터 6초 — 3초에 다시 쓴 것으로 창이 늘었다면 여기서도 묻지 않았을 것이다 + api.clock.advance(const Duration(seconds: 3)); + await errorOf(api.dio.get(_mailState)); + expect(api.recheckCount, 2); + }, + ); + }); } diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 5394d26..9f0a25f 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -660,4 +660,90 @@ void main() { expect(h.store.hasSession, isFalse); }); }); + + group('3차 — 남은 비밀 · 기다림', () { + test( + 'signedIn 에 토큰은 실렸는데 모양이 틀리면 응답을 뺀 실패로 던지고 그 서버 세션을 조용히 끝낸다', + () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({ + 'status': 'signedIn', + 'email': 'ara.kim@arasystem.it.kr', + 'tokens': tokensJson(1), + }), + ); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + final Object error = await errorOf( + notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'), + ); + + final DioException failure = error as DioException; + expect(apiErrorOf(failure).code, 'SYS_001'); + expect(failure.response, isNull); + expect(failure.requestOptions.data, isNull); + expect(h.store.hasSession, isFalse); + await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃'); + expect(logouts().single.data, { + 'refreshToken': 'refresh-1', + }); + expect(bearerTokenOf(logouts().single), 'access-1'); + }, + ); + + test( + '다시 맞추기를 기다리는 동안 로그아웃하면 stale 로 답해 막힌 요청을 놓아주고, 새 로그인 오류가 그 뒤에서 기다리지 않는다', + () async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + final Completer slowMe = Completer(); + int meCalls = 0; + h.server.on('GET', ApiPaths.me, (_) async { + meCalls++; + if (meCalls > 1) await slowMe.future; + return FakeResponse.ok(sessionJson()); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + expect(await settled(), isA()); + + final Future blocked = errorOf( + container.read(dioProvider).get('/mailboxes'), + ); + await pumpEventsUntil( + () => meCalls == 2, + reason: '다시 맞추기의 me 가 서버에 닿기', + ); + + await notifier().switchAccount(); + + // 앞 세션의 me 는 아직 끝나지 않았지만 막힌 요청은 조용히 끝난다 + expect(apiErrorOf(await blocked).code, 'AUTH_010'); + expect(h.notifier.shownCodes, isEmpty); + // 다른 계정의 새 로그인 오류도 기다리지 않고 온다 + final Object loginError = await errorOf( + notifier().signIn(email: 'other@arasystem.it.kr', password: 'wrong'), + ); + expect(apiErrorOf(loginError).code, 'AUTH_004'); + expect(slowMe.isCompleted, isFalse); + + slowMe.complete(); + await pumpEventsUntil(() => true); + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + }, + ); + }); } -- 2.52.0 From c14580e3a4b5160818324e9b221d0620adbbb70c Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 15:57:39 +0900 Subject: [PATCH 02/32] =?UTF-8?q?feat(flutter):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=84=A4=20=EB=8B=A8=EA=B3=84=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EB=A7=8C=EB=93=A0=EB=8B=A4=20(=EC=95=B1=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=E2=80=94=20App1Login)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를 세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인]. - 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3, canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸 - 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서 상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만 - 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다), 비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다 - 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판) - 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건 리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판, 계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음. Co-Authored-By: Claude Opus 5.5 --- flutter/assets/brand/logo-ara-white.png | Bin 0 -> 12602 bytes flutter/assets/brand/logo-arasystem-white.png | Bin 0 -> 63405 bytes flutter/lib/core/api/api_config.dart | 4 + flutter/lib/core/api/api_paths.dart | 16 + flutter/lib/core/api/error_notifier.dart | 5 +- flutter/lib/core/constants/app_assets.dart | 10 + flutter/lib/core/constants/app_constants.dart | 4 + flutter/lib/core/constants/login_texts.dart | 164 ++ flutter/lib/core/constants/screen_titles.dart | 22 +- flutter/lib/core/router/app_router.dart | 27 +- flutter/lib/core/theme/app_spacing.dart | 27 + flutter/lib/core/theme/app_theme.dart | 32 + flutter/lib/core/theme/app_typography.dart | 12 + flutter/lib/core/utils/display_text.dart | 40 + flutter/lib/core/utils/korean_text.dart | 34 + flutter/lib/core/utils/otp_code.dart | 33 + flutter/lib/core/utils/password_rules.dart | 61 + .../auth/data/authenticator_launcher.dart | 37 + .../data/models/two_factor_enrollment.dart | 48 + .../auth/data/models/two_factor_status.dart | 17 + .../data/repositories/auth_repository.dart | 44 +- .../support_contact_repository.dart | 26 + .../repositories/two_factor_repository.dart | 84 + .../auth/presentation/login_failures.dart | 28 + .../presentation/pages/login_code_page.dart | 162 ++ .../auth/presentation/pages/login_page.dart | 218 ++ .../pages/password_change_page.dart | 436 ++++ .../pages/restricted_login_page.dart | 34 + .../pages/two_factor_setup_page.dart | 633 ++++++ .../providers/login_providers.dart | 62 + .../presentation/providers/login_secret.dart | 18 +- .../providers/session_provider.dart | 205 +- .../presentation/providers/session_state.dart | 14 +- .../presentation/widgets/login_fields.dart | 395 ++++ .../presentation/widgets/login_frame.dart | 376 ++++ .../presentation/widgets/login_parts.dart | 317 +++ flutter/lib/shared/widgets/keep_all_text.dart | 17 + .../flutter/generated_plugin_registrant.cc | 4 + flutter/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + flutter/pubspec.lock | 64 + flutter/pubspec.yaml | 5 + flutter/test/app_test.dart | 14 +- .../test/core/api/auth_interceptor_test.dart | 46 +- flutter/test/core/utils/login_input_test.dart | 136 ++ flutter/test/error_lexicon_test.dart | 6 +- .../test/features/auth/login_pages_test.dart | 1873 +++++++++++++++++ .../test/features/auth/login_secret_test.dart | 47 + .../features/auth/restricted_steps_test.dart | 452 ++++ .../features/auth/session_provider_test.dart | 12 +- .../features/auth/two_factor_parts_test.dart | 159 ++ flutter/test/support/login_fakes.dart | 183 ++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 54 files changed, 6597 insertions(+), 73 deletions(-) create mode 100644 flutter/assets/brand/logo-ara-white.png create mode 100644 flutter/assets/brand/logo-arasystem-white.png create mode 100644 flutter/lib/core/constants/app_assets.dart create mode 100644 flutter/lib/core/constants/login_texts.dart create mode 100644 flutter/lib/core/utils/display_text.dart create mode 100644 flutter/lib/core/utils/korean_text.dart create mode 100644 flutter/lib/core/utils/otp_code.dart create mode 100644 flutter/lib/core/utils/password_rules.dart create mode 100644 flutter/lib/features/auth/data/authenticator_launcher.dart create mode 100644 flutter/lib/features/auth/data/models/two_factor_enrollment.dart create mode 100644 flutter/lib/features/auth/data/models/two_factor_status.dart create mode 100644 flutter/lib/features/auth/data/repositories/support_contact_repository.dart create mode 100644 flutter/lib/features/auth/data/repositories/two_factor_repository.dart create mode 100644 flutter/lib/features/auth/presentation/login_failures.dart create mode 100644 flutter/lib/features/auth/presentation/pages/login_code_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/login_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/password_change_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/restricted_login_page.dart create mode 100644 flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart create mode 100644 flutter/lib/features/auth/presentation/providers/login_providers.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_fields.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_frame.dart create mode 100644 flutter/lib/features/auth/presentation/widgets/login_parts.dart create mode 100644 flutter/lib/shared/widgets/keep_all_text.dart create mode 100644 flutter/test/core/utils/login_input_test.dart create mode 100644 flutter/test/features/auth/login_pages_test.dart create mode 100644 flutter/test/features/auth/login_secret_test.dart create mode 100644 flutter/test/features/auth/restricted_steps_test.dart create mode 100644 flutter/test/features/auth/two_factor_parts_test.dart create mode 100644 flutter/test/support/login_fakes.dart diff --git a/flutter/assets/brand/logo-ara-white.png b/flutter/assets/brand/logo-ara-white.png new file mode 100644 index 0000000000000000000000000000000000000000..6766d3ac2ff359e36cc2e066fee266e29db3f0d2 GIT binary patch literal 12602 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⁡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 literal 0 HcmV?d00001 diff --git a/flutter/lib/core/api/api_config.dart b/flutter/lib/core/api/api_config.dart index 91b7cb6..cfca0d0 100644 --- a/flutter/lib/core/api/api_config.dart +++ b/flutter/lib/core/api/api_config.dart @@ -11,6 +11,10 @@ const String kDefaultApiUrl = 'http://localhost:3000/api'; const Duration kApiConnectTimeout = Duration(seconds: 15); const Duration kApiReceiveTimeout = Duration(seconds: 15); +/// 보안 설정(비밀번호 바꾸기 · 2단계 인증 켜기)의 받기 한도 — 서버가 엔진을 여러 번 부르고 세션을 끊는다. +/// 기본 한도로 끊으면 화면은 실패로 보는데 서버는 이미 바꿨을 수 있다(계약 1절 · 웹 `PASSWORD_CHANGE_TIMEOUT_MS` 와 같은 30초) +const Duration kSecuritySettingTimeout = Duration(seconds: 30); + /// API 연결 설정 class ApiConfig { const ApiConfig({ diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index f1ceb8d..f0409f9 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -8,6 +8,22 @@ abstract final class ApiPaths { /// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다 static const String me = '/auth/me'; + // ── 로그인 틀의 제한 단계 (계약 5 · 6절 — 제한된 세션도 부를 수 있는 경로) ── + /// 비밀번호 바꾸기 — 새 비밀번호 단계 + static const String password = '/auth/password'; + + /// 2단계 인증 상태 — 새 비밀번호 단계가 코드 칸을 보일지 · 그사이 다른 곳에서 켰는지 + static const String twoFactor = '/two-factor'; + + /// 2단계 인증 등록 시작 — 새 키(10분) + static const String twoFactorEnrollments = '/two-factor/enrollments'; + + /// 2단계 인증 켜기 + static const String twoFactorActivation = '/two-factor/activation'; + + /// 문의 연락처 (로그인 없음) + static const String supportContact = '/support-contact'; + /// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일") static const Set noRefresh = { appLogin, diff --git a/flutter/lib/core/api/error_notifier.dart b/flutter/lib/core/api/error_notifier.dart index f24b587..943fab6 100644 --- a/flutter/lib/core/api/error_notifier.dart +++ b/flutter/lib/core/api/error_notifier.dart @@ -1,5 +1,6 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; @@ -54,7 +55,8 @@ class ErrorNotifier { /// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다). /// /// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패 -/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다). +/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다) · +/// 세션 끝(`AUTH_002`) — 세션이 끝나면 늘 로그인 화면으로 가고, 그 화면의 위쪽 상자가 같은 사전 문구로 알린다(두 번 뜨지 않게). class ErrorNotifyInterceptor extends Interceptor { ErrorNotifyInterceptor(this._notifier); @@ -65,6 +67,7 @@ class ErrorNotifyInterceptor extends Interceptor { final Object? error = err.error; if (err.type != DioExceptionType.cancel && error is ApiException && + error.code != kSessionExpiredCode && !isSilentFor(err.requestOptions, error.code)) { _notifier.show(error); } diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart new file mode 100644 index 0000000..ef602dc --- /dev/null +++ b/flutter/lib/core/constants/app_assets.dart @@ -0,0 +1,10 @@ +/// 앱에 넣은 그림 파일 경로 (pubspec `assets`). +/// +/// 흰 로고 둘은 웹 `frontend/src/assets/brand/` 와 같은 파일이다(원본 `docs/brand/`) — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. +abstract final class AppAssets { + /// 전체 로고(ARA SYSTEM) — 로그인 첫 화면의 브랜드 띠. 원본 960×459 + static const String logoFull = 'assets/brand/logo-arasystem-white.png'; + + /// ARA 마크 — 로그인 다음 단계의 짙은 띠. 원본 320×111 + static const String logoMark = 'assets/brand/logo-ara-white.png'; +} diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index fec044b..7787fa6 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -7,6 +7,10 @@ library; /// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 const String kAppName = 'ARA SYSTEM'; +/// 회사 메일 도메인 — 화면 안내 글에만 쓴다(로그인 화면의 "회사 메일 주소(@…)"). +/// 허용 도메인 검사는 서버가 한다. 웹 `frontend/src/shared/constants/brand.ts` 의 `COMPANY_MAIL_DOMAIN` 과 같은 값 +const String kCompanyMailDomain = 'arasystem.it.kr'; + /// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)` /// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다) const String kUserAgentProduct = 'ARASYSTEM-App'; diff --git a/flutter/lib/core/constants/login_texts.dart b/flutter/lib/core/constants/login_texts.dart new file mode 100644 index 0000000..6f4e0d8 --- /dev/null +++ b/flutter/lib/core/constants/login_texts.dart @@ -0,0 +1,164 @@ +/// 로그인 틀 네 단계의 화면 글 — 원본은 앱 보드 `App1Login`(`docs/design/webmail/App1Login.dc.html`)이다. +/// +/// 보드에 없는 상태(보내는 중 · 임시 비밀번호 칸 · 코드 칸 · 마친 뒤 알림 등)의 글은 웹 로그인 틀의 상수 +/// (`frontend/src/shared/constants/account-security.ts` · `session-restriction.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다. +/// 제목은 `screen_titles.dart`. 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/constants/app_constants.dart'; + +/// 첫 단계 — 주소와 비밀번호 +abstract final class LoginTexts { + /// 브랜드 띠의 큰 글 + static const String brandHeadline = '사내 업무를 한 계정으로'; + + /// 브랜드 띠의 소개 글 + static const String brandLead = '메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.'; + + static const String subtitle = '회사 메일 주소(@$kCompanyMailDomain)로 로그인해 주세요.'; + + static const String emailLabel = '메일 주소'; + static const String emailPlaceholder = 'name@$kCompanyMailDomain'; + static const String passwordLabel = '비밀번호'; + static const String passwordPlaceholder = '비밀번호 입력'; + + /// [보기] 단추의 이름 (눌러 둔 상태는 화면 낭독기가 따로 읽는다) + static const String showPassword = '비밀번호 보기'; + + static const String submit = '로그인'; + + /// 보내는 동안 — 웹과 같다 + static const String submitting = '로그인하는 중…'; + + /// 코드 단계에 10분 넘게 머문 뒤 [확인]을 눌러 돌아왔을 때 위쪽 상자 (계약 5절 "로그인" 클라이언트 — 웹과 같다) + static const String codeTimedOut = '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.'; +} + +/// 다음 단계들의 짙은 띠 +abstract final class LoginBandTexts { + static const String otherAccount = '다른 계정으로 로그인'; +} + +/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 지금 글 그대로(웹과 같다) +abstract final class SupportLineTexts { + /// 첫 단계 + static const String passwordQuestion = '비밀번호를 잊으셨나요?'; + static const String passwordFallback = '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.'; + + /// 코드 단계 · 2단계 인증 켜기 + static const String authenticatorQuestion = '인증 앱을 쓸 수 없나요?'; + static const String authenticatorFallback = '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.'; +} + +/// 둘째 단계 — 2단계 인증 코드 (2단계를 켠 직원) +abstract final class LoginCodeTexts { + static const String subtitle = '비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.'; + static const String codeLabel = '인증 앱에 표시된 6자리 코드'; + static const String submit = '확인'; + static const String submitting = '확인하는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; +} + +/// 인증 앱 코드 칸 (코드 단계 · 새 비밀번호 단계 · 2단계 켜기) +abstract final class OtpTexts { + static const String placeholder = '000000'; +} + +/// 제한 단계 공용 — 단계는 마쳤지만 다음으로 넘기지 못했을 때(새 토큰 받기 · 세션 확인 모두 장애) 위쪽 상자의 단추 +abstract final class RestrictedStepTexts { + static const String retry = '다시 시도'; +} + +/// 셋째 단계 — 새 비밀번호 정하기 (제한 `passwordChange`) +abstract final class PasswordChangeTexts { + static const String subtitle = + '관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.'; + + /// 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) 맨 위에 — 웹과 같다 + static const String temporaryLabel = '임시 비밀번호'; + static const String showTemporary = '임시 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 + static const String temporaryHint = '관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.'; + + static const String nextLabel = '새 비밀번호'; + static const String showNext = '새 비밀번호 보기'; + + /// 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 + static const String nextHint = + '8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.'; + + /// 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + static const String confirmLabel = '새 비밀번호 확인'; + static const String mismatch = '새 비밀번호가 서로 다릅니다.'; + + /// 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 웹과 같다) + static const String codeLabel = '인증 앱 코드'; + static const String codeHint = + '로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.'; + + static const String submit = '비밀번호 바꾸고 계속'; + static const String submitting = '바꾸는 중…'; + static const String note = '아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.'; + + /// 마친 뒤 알림 — 웹과 같다 + static const String done = '새 비밀번호를 정했습니다.'; +} + +/// 넷째 단계 — 2단계 인증 켜기 (제한 `twoFactorSetup`). 같은 휴대폰이라 QR 대신 [인증 앱에 추가] · 키 · [복사] +abstract final class TwoFactorSetupTexts { + static const String subtitle = + '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.'; + + /// ① 인증 앱에 추가 + static const String addTitle = '인증 앱에 추가해 주세요'; + static const String addHint = + '이 휴대폰의 인증 앱에 바로 추가합니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.'; + static const String addButton = '인증 앱에 추가'; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했을 때 + static const String addFailed = + '이 휴대폰에서 인증 앱을 찾지 못했습니다. 인증 앱을 설치한 뒤 다시 눌러 주세요. 아래 키를 직접 입력해도 됩니다.'; + + static const String keyLabel = '추가되지 않으면 이 키를 인증 앱에 직접 입력해 주세요'; + static const String copy = '복사'; + + /// [복사]의 화면 낭독기 이름 + static const String copyLabel = '키 복사'; + + /// 복사한 뒤 알림 — 웹과 같다. 키는 클립보드에 남는다(앱이 지우지 않는다) + static const String copied = '키를 복사했습니다.'; + + /// 복사하지 못했을 때 — 앱의 키는 골라 복사할 수 없어 웹 글("직접 골라 복사")을 고쳤다 + static const String copyFailed = '키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.'; + + /// 인증 앱에 저장될 이름 — 서버의 otpauth 주소와 같은 이름(계약 5절) + static String savedAs(String email) => '앱에는 ‘ARA SYSTEM ($email)’로 저장됩니다.'; + + /// 키를 받는 동안 회색 자리의 화면 낭독기 글 + static const String keyLoading = '키를 만드는 중…'; + + /// 키를 받지 못했을 때 사전 문구 옆의 단추 — 웹과 같다 + static const String retry = '다시 시도'; + + /// ② 코드 + static const String codeLabel = '앱에 표시된 6자리 코드'; + static const String codeHint = '인증 앱에서 코드를 복사해 와서 붙여 넣어도 됩니다.'; + + /// ③ 지금 비밀번호 — 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) + static const String passwordLabel = '지금 비밀번호'; + static const String showPassword = '지금 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 (웹의 "새로고침한 뒤에는…"을 앱에 맞게 고쳤다) + static const String passwordHint = '지금 비밀번호를 한 번 더 넣어 주세요.'; + + static const String submit = '켜고 계속'; + static const String submitting = '켜는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 켠 뒤에는 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; + + /// 켠 뒤 알림 — 웹과 같다(웹의 [앱 비밀번호 만들기] 단추는 앱에 그 화면이 없어 뺐다) + static const String done = '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.'; +} diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart index d65c4b5..5bdc98a 100644 --- a/flutter/lib/core/constants/screen_titles.dart +++ b/flutter/lib/core/constants/screen_titles.dart @@ -1,24 +1,20 @@ -/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다. -/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`). +/// 로그인 전 · 제한 단계 화면의 제목 — 앱 보드 `App1Login` 의 제목 그대로다(화면 낭독기가 단계가 바뀔 때 이 글을 읽는다). +/// 그 밖의 화면 글은 `login_texts.dart`, 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`)에 둔다. /// -/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고 -/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class ScreenTitles { - /// 세션을 되살리는 동안 + /// 세션을 되살리는 동안 — 아직 자리 화면(보드 없음) static const String start = '시작하는 중'; - /// 로그인 — 비밀번호 단계 + /// 로그인 — 주소와 비밀번호 static const String login = '로그인'; - /// 로그인 — 2단계 인증 코드 단계 - static const String loginCode = '인증 코드 입력'; + /// 로그인 — 2단계 인증 코드 + static const String loginCode = '2단계 인증'; /// 제한된 세션 — 새 비밀번호 정하기 (passwordChange) - static const String passwordChange = '새 비밀번호 정하기'; + static const String passwordChange = '새 비밀번호를 정해 주세요'; /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) - static const String twoFactorSetup = '2단계 인증 켜기'; - - /// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만) - static const String restricted = '로그인 제한'; + static const String twoFactorSetup = '2단계 인증을 켜 주세요'; } diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 23d2d80..8a0784b 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -5,6 +5,11 @@ import 'package:arasystem_app/core/router/app_routes.dart'; import 'package:arasystem_app/core/router/app_shell.dart'; import 'package:arasystem_app/core/router/module_registry.dart'; import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/restricted_login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_page.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; @@ -24,8 +29,8 @@ final Provider> appModulesProvider = Provider>( /// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다. /// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException). /// -/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면 -/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다). +/// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다. +/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). final Provider routerProvider = Provider((Ref ref) { final List modules = ref.watch(appModulesProvider); final List enabled = enabledAppModules(modules); @@ -59,11 +64,11 @@ final Provider routerProvider = Provider((Ref ref) { router.go(homeLocationOf(modules)), routes: [ _placeholderRoute(AppRoutes.start, ScreenTitles.start), - _placeholderRoute(AppRoutes.login, ScreenTitles.login), - _placeholderRoute(AppRoutes.loginCode, ScreenTitles.loginCode), - _placeholderRoute(AppRoutes.passwordChange, ScreenTitles.passwordChange), - _placeholderRoute(AppRoutes.twoFactorSetup, ScreenTitles.twoFactorSetup), - _placeholderRoute(AppRoutes.restricted, ScreenTitles.restricted), + _loginRoute(AppRoutes.login, const LoginPage()), + _loginRoute(AppRoutes.loginCode, const LoginCodePage()), + _loginRoute(AppRoutes.passwordChange, const PasswordChangePage()), + _loginRoute(AppRoutes.twoFactorSetup, const TwoFactorSetupPage()), + _loginRoute(AppRoutes.restricted, const RestrictedLoginPage()), StatefulShellRoute.indexedStack( builder: ( @@ -94,6 +99,14 @@ GoRoute _placeholderRoute(String path, String title) { ); } +/// 로그인 틀의 한 단계 — 세션이 바뀌면 redirect 가 단계를 바꾼다(화면끼리 서로 옮기지 않는다) +GoRoute _loginRoute(String path, Widget page) { + return GoRoute( + path: path, + builder: (BuildContext context, GoRouterState state) => page, + ); +} + /// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable) class _SessionChanges extends ChangeNotifier { void notify() => notifyListeners(); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 0d28bbf..beade93 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -14,9 +14,14 @@ abstract final class AppSpacing { static const double s12 = 12; static const double s14 = 14; static const double s16 = 16; + static const double s18 = 18; static const double s20 = 20; + static const double s22 = 22; static const double s24 = 24; static const double s28 = 28; + + /// 웹 토큰에는 없다 — 앱 보드(`App1Login`)의 화면 아래 여백 + static const double s32 = 32; static const double s36 = 36; static const double s40 = 40; static const double s48 = 48; @@ -52,4 +57,26 @@ abstract final class AppSize { /// 초점 고리 두께 — `--focus-ring-width` 2px static const double focusRingWidth = 2; + + // ── 앱 보드에서 정한 크기 (`App1Login` — 2026-11-05 확정) ── + /// 글자 입력 칸 높이 + static const double fieldHeight = 52; + + /// 인증 앱 코드 칸 높이 (글자가 크다) + static const double codeFieldHeight = 56; + + /// 위 띠 높이 — 로그인 다음 단계의 짙은 띠 + static const double topBandHeight = 56; + + /// 알약 모양 줄(로그인한 주소) 높이 + static const double chipHeight = 36; + + /// 작은 아이콘(단추 안 · 안내 상자) + static const double iconSmall = 18; + + /// 보통 아이콘(칸 안 [보기]) + static const double iconMedium = 20; + + /// 넓은 화면(태블릿 · 가로)에서 양식 열의 최대 폭 — 휴대폰(390)에서는 화면 폭을 다 쓴다 + static const double formMaxWidth = 480; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 45038b9..2bca6ef 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -159,7 +159,11 @@ class AppExtraColors extends ThemeExtension { const AppExtraColors({ required this.textMuted, required this.textBody, + required this.textStrong, required this.iconMuted, + required this.borderSubtle, + required this.onBrandMuted, + required this.onBrandLink, required this.warningSurface, required this.warningText, required this.warningBorder, @@ -170,7 +174,11 @@ class AppExtraColors extends ThemeExtension { static const AppExtraColors light = AppExtraColors( textMuted: AppColors.textMuted, textBody: AppColors.textBody, + textStrong: AppColors.textStrong, iconMuted: AppColors.iconMuted, + borderSubtle: AppColors.borderSubtle, + onBrandMuted: AppColors.onBrandMuted, + onBrandLink: AppColors.onBrandLink, warningSurface: AppColors.warningSurface, warningText: AppColors.warningText, warningBorder: AppColors.warningBorder, @@ -183,8 +191,20 @@ class AppExtraColors extends ThemeExtension { /// 메일 본문 final Color textBody; + + /// 입력 이름 · 도구 단추 · 연락처 + final Color textStrong; final Color iconMuted; + /// 옅은 나눔선 — 화면 맨 아래 안내 글 위 + final Color borderSubtle; + + /// 브랜드 바탕 위 보조 글 (로그인 첫 화면의 소개 글) + final Color onBrandMuted; + + /// 브랜드 바탕 위 링크 · 단추 ([다른 계정으로 로그인]) + final Color onBrandLink; + /// 노란 안내 줄 final Color warningSurface; final Color warningText; @@ -198,7 +218,11 @@ class AppExtraColors extends ThemeExtension { AppExtraColors copyWith({ Color? textMuted, Color? textBody, + Color? textStrong, Color? iconMuted, + Color? borderSubtle, + Color? onBrandMuted, + Color? onBrandLink, Color? warningSurface, Color? warningText, Color? warningBorder, @@ -208,7 +232,11 @@ class AppExtraColors extends ThemeExtension { return AppExtraColors( textMuted: textMuted ?? this.textMuted, textBody: textBody ?? this.textBody, + textStrong: textStrong ?? this.textStrong, iconMuted: iconMuted ?? this.iconMuted, + borderSubtle: borderSubtle ?? this.borderSubtle, + onBrandMuted: onBrandMuted ?? this.onBrandMuted, + onBrandLink: onBrandLink ?? this.onBrandLink, warningSurface: warningSurface ?? this.warningSurface, warningText: warningText ?? this.warningText, warningBorder: warningBorder ?? this.warningBorder, @@ -223,7 +251,11 @@ class AppExtraColors extends ThemeExtension { return AppExtraColors( textMuted: Color.lerp(textMuted, other.textMuted, t)!, textBody: Color.lerp(textBody, other.textBody, t)!, + textStrong: Color.lerp(textStrong, other.textStrong, t)!, iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!, + borderSubtle: Color.lerp(borderSubtle, other.borderSubtle, t)!, + onBrandMuted: Color.lerp(onBrandMuted, other.onBrandMuted, t)!, + onBrandLink: Color.lerp(onBrandLink, other.onBrandLink, t)!, warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!, warningText: Color.lerp(warningText, other.warningText, t)!, warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!, diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart index 58f2c1d..4fb9b8b 100644 --- a/flutter/lib/core/theme/app_typography.dart +++ b/flutter/lib/core/theme/app_typography.dart @@ -35,10 +35,22 @@ abstract final class AppTypography { /// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정) static const double lineHeightBody = 1.75; + /// 여러 줄 안내 글 · 2단계 인증 키 (앱 보드 `App1Login` 의 1.6) + static const double lineHeightRelaxed = 1.6; + // ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ── static const double letterSpacingTightEm = -0.01; static const double letterSpacingHeadingEm = -0.02; + /// 인증 앱 코드 칸 — 숫자 사이를 벌린다 (앱 보드 `App1Login` 의 0.35em) + static const double letterSpacingCodeEm = 0.35; + + /// 2단계 인증 키 — 고정폭 글자 사이 (앱 보드 `App1Login` 의 0.03em) + static const double letterSpacingKeyEm = 0.03; + + /// 고정폭 글꼴 — 2단계 인증 키. 기기의 고정폭 글꼴을 쓴다(Android 는 'monospace' 가 Droid Sans Mono 계열) + static const String monospaceFamily = 'monospace'; + // ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ── static const FontWeight regular = FontWeight.w400; static const FontWeight medium = FontWeight.w500; diff --git a/flutter/lib/core/utils/display_text.dart b/flutter/lib/core/utils/display_text.dart new file mode 100644 index 0000000..ef39450 --- /dev/null +++ b/flutter/lib/core/utils/display_text.dart @@ -0,0 +1,40 @@ +/// 화면에 보이기 전에 글을 다듬는 순수 함수 — 문의 연락처 한 줄 · 2단계 인증 키 줄. +library; + +import 'dart:math' as math; + +/// 문의 연락처의 길이 상한 — 서버 설정값과 같은 200자 (계약 6절 "문의 연락처") +const int kSupportContactMaxLength = 200; + +final RegExp _whitespaceRun = RegExp(r'\s+'); + +/// 문의 연락처 — 서버 설정값을 화면에 보일 한 줄로 다듬는다(웹 `support-contact.ts` 와 같다). +/// +/// 줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지우고, 200자에서 자른다. 글자로만 보인다(링크 없음). +/// 빈 글 · 글이 아닌 값은 null — 정하지 않은 것으로 보고 화면은 지금 글 그대로다. +String? supportContactTextOf(Object? value) { + if (value is! String) return null; + final String line = value.replaceAll(_whitespaceRun, ' ').trim(); + if (line.isEmpty) return null; + return line.length > kSupportContactMaxLength + ? line.substring(0, kSupportContactMaxLength) + : line; +} + +/// 2단계 인증 키를 끊어 보이는 크기 — 4자씩, 한 줄에 4묶음 (32자 키는 두 줄 — 앱 보드 `App1Login`, 웹과 같다) +const int kKeyGroupSize = 4; +const int kKeyGroupsPerLine = 4; + +/// 2단계 인증 키의 줄 — "K7QF 2M4X HB6R TZ3N" 꼴. 보기 위한 띄어쓰기다 — [복사]는 키를 띄어쓰기 없이 복사한다 +List keyLines(String secret) { + final List groups = [ + for (int start = 0; start < secret.length; start += kKeyGroupSize) + secret.substring(start, math.min(start + kKeyGroupSize, secret.length)), + ]; + return [ + for (int start = 0; start < groups.length; start += kKeyGroupsPerLine) + groups + .sublist(start, math.min(start + kKeyGroupsPerLine, groups.length)) + .join(' '), + ]; +} diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart new file mode 100644 index 0000000..8c56773 --- /dev/null +++ b/flutter/lib/core/utils/korean_text.dart @@ -0,0 +1,34 @@ +/// 한국어 글의 줄 나눔 — 웹 `word-break: keep-all` 에 해당한다(앱 보드 `App1Login` 의 안내 글). +library; + +/// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) +const int _wordJoiner = 0x2060; + +/// 낱말 가운데서 줄이 바뀌지 않게 한다 — 줄은 띄어쓰기에서만 바뀐다. +/// +/// Flutter 의 줄 나눔(ICU)은 한글 글자 사이마다 줄을 바꿀 수 있어 "자동으 / 로" 처럼 낱말이 갈린다(Flutter 에 keep-all 이 아직 없다). +/// 한글 글자와 그 이웃(한글 · 영문 · 숫자 · ASCII 기호) 사이에 WORD JOINER 를 넣는다 — "Outlook이나" · "[설정]에서" 도 한 낱말로 남는다. +/// 그 밖의 글자(이모지 · 결합 문자 등) 옆에는 넣지 않는다(글자 묶음을 가르지 않게). 한 낱말이 한 줄보다 길면 Flutter 가 그 안에서 끊는다. +/// 화면 낭독기에는 원래 글을 준다(`KeepAllText` 의 `semanticsLabel`). +String keepAllWordBreak(String text) { + final StringBuffer out = StringBuffer(); + int? previous; + for (final int rune in text.runes) { + if (previous != null && + _joinable(previous) && + _joinable(rune) && + (_isHangulSyllable(previous) || _isHangulSyllable(rune))) { + out.writeCharCode(_wordJoiner); + } + out.writeCharCode(rune); + previous = rune; + } + return out.toString(); +} + +bool _isHangulSyllable(int rune) => rune >= 0xAC00 && rune <= 0xD7A3; + +/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 +bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; + +bool _joinable(int rune) => _isHangulSyllable(rune) || _isAsciiGraphic(rune); diff --git a/flutter/lib/core/utils/otp_code.dart b/flutter/lib/core/utils/otp_code.dart new file mode 100644 index 0000000..1581d25 --- /dev/null +++ b/flutter/lib/core/utils/otp_code.dart @@ -0,0 +1,33 @@ +/// 인증 앱 코드 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙"). 웹 `frontend/src/shared/utils/account-security.ts` 와 같은 규칙이다. +library; + +/// 코드의 자릿수 +const int kOtpCodeLength = 6; + +/// 전각 숫자 0 ~ 9 의 첫 글자 — 반각 숫자로 바꾼다(웹은 NFKC 로 같은 일을 한다) +const int _fullWidthZero = 0xFF10; +const int _fullWidthNine = 0xFF19; +const int _asciiZero = 0x30; +const int _asciiNine = 0x39; + +/// 코드 칸에 들어온 글을 다듬는다 — 숫자만 남기고 6자리에서 자른다. +/// +/// 붙여 넣은 "123 456" · "123-456" 과 전각 숫자도 받는다(계약 5절 "로그인" 클라이언트). +/// 칸의 글자 수 한도(maxLength)를 따로 걸지 않는 까닭: 한도가 먼저 잘라 "123 45" 만 남는다. +String normalizeOtpInput(String raw) { + final StringBuffer digits = StringBuffer(); + for (final int rune in raw.runes) { + if (digits.length == kOtpCodeLength) break; + if (rune >= _asciiZero && rune <= _asciiNine) { + digits.writeCharCode(rune); + } else if (rune >= _fullWidthZero && rune <= _fullWidthNine) { + digits.writeCharCode(_asciiZero + rune - _fullWidthZero); + } + } + return digits.toString(); +} + +final RegExp _completeOtp = RegExp('^[0-9]{$kOtpCodeLength}\$'); + +/// 숫자 6자리를 다 넣었는가 — 서버도 같은 모양만 받는다(아니면 400 `VAL_001`) +bool isCompleteOtp(String code) => _completeOtp.hasMatch(code); diff --git a/flutter/lib/core/utils/password_rules.dart b/flutter/lib/core/utils/password_rules.dart new file mode 100644 index 0000000..6078d52 --- /dev/null +++ b/flutter/lib/core/utils/password_rules.dart @@ -0,0 +1,61 @@ +/// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙을 먼저 본다 (계약 1절 "PUT /api/auth/password"). +/// +/// 웹 `frontend/src/shared/utils/password-change.ts` 와 같은 규칙이다: 8자 이상 · 지금 비밀번호와 다름 · 확인 칸 일치, +/// 128자를 넘으면 서버가 `VAL_001` 로 거절하므로 그것도 먼저 막는다. 공백도 비밀번호의 글자다 — 다듬지 않는다. +/// 길이는 서버와 같이 UTF-16 코드 단위로 센다(Dart `String.length` 가 JS 와 같다). +library; + +/// 새 비밀번호의 최소 길이 +const int kPasswordMinLength = 8; + +/// 새 비밀번호의 최대 길이 +const int kPasswordMaxLength = 128; + +/// 로그인 · 지금 비밀번호의 최대 길이 — 넘으면 서버가 `VAL_001` (계약 1절) +const int kLoginPasswordMaxLength = 256; + +/// 새 비밀번호 칸의 문제 +enum NewPasswordProblem { + /// 8자 미만이거나 지금 비밀번호와 같다 — 안내 글이 그대로 빨간 오류가 된다 + rule, + + /// 128자 초과 + tooLong, +} + +/// 칸마다 하나 — 없으면 null +class PasswordChangeProblems { + const PasswordChangeProblems({ + this.currentMissing = false, + this.next, + this.confirmMismatch = false, + }); + + /// 지금(임시) 비밀번호를 비웠다 + final bool currentMissing; + final NewPasswordProblem? next; + + /// 확인 칸이 새 비밀번호와 다르다 + final bool confirmMismatch; + + bool get hasAny => currentMissing || next != null || confirmMismatch; +} + +/// 세 칸을 본다 — [current] 는 쥔 비밀번호(로그인 때 넣은 것)이거나 칸에 넣은 임시 비밀번호 +PasswordChangeProblems passwordChangeProblems({ + required String current, + required String next, + required String confirm, +}) { + NewPasswordProblem? nextProblem; + if (next.length > kPasswordMaxLength) { + nextProblem = NewPasswordProblem.tooLong; + } else if (next.length < kPasswordMinLength || next == current) { + nextProblem = NewPasswordProblem.rule; + } + return PasswordChangeProblems( + currentMissing: current.isEmpty, + next: nextProblem, + confirmMismatch: confirm != next, + ); +} diff --git a/flutter/lib/features/auth/data/authenticator_launcher.dart b/flutter/lib/features/auth/data/authenticator_launcher.dart new file mode 100644 index 0000000..aec65b9 --- /dev/null +++ b/flutter/lib/features/auth/data/authenticator_launcher.dart @@ -0,0 +1,37 @@ +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef UrlLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// [인증 앱에 추가] — otpauth 주소(키가 들어 있다)를 그 주소를 받는 인증 앱에 넘긴다(2단계 켜기 · 같은 휴대폰이라 QR 대신). +/// +/// - 브라우저가 아닌 앱만 연다(`externalNonBrowserApplication` — Android 11+ 의 FLAG_ACTIVITY_REQUIRE_NON_BROWSER). +/// 받을 앱이 없으면 false — 화면이 "인증 앱을 찾지 못했습니다"와 키를 직접 넣는 길을 보인다. +/// - **`canLaunchUrl` 을 쓰지 않는다** — url_launcher_android 의 debug 판이 묻는 주소를 로그에 남긴다(키가 들어 있다). +/// 그래서 매니페스트 `` 도 필요 없다: `launchUrl` 은 startActivity 를 바로 부르고 받을 앱이 없으면 +/// ActivityNotFoundException 을 false 로 바꾼다(앱 보기 제한은 resolveActivity 류에만 걸린다). +/// - 못 열면 플러그인이 던지는 `PlatformException` 의 문장에 주소째(키째) 들어 있다 — 잡아서 버린다(로그 · 상태에 남기지 않는다). +class AuthenticatorLauncher { + const AuthenticatorLauncher({UrlLaunch launch = _launchExternal}) + : _launch = launch; + + final UrlLaunch _launch; + + /// 인증 앱을 열었으면 true. otpauth 주소가 아니거나 받을 앱이 없거나 열지 못했으면 false + Future open(String otpauthUrl) async { + if (!otpauthUrl.startsWith(kOtpauthUrlPrefix)) return false; + final Uri? url = Uri.tryParse(otpauthUrl); + if (url == null) return false; + try { + return await _launch(url, LaunchMode.externalNonBrowserApplication); + } on Object { + // PlatformException(받을 앱 없음 · 화면 없음) · ArgumentError(지원하지 않는 방식) 모두 — + // 문장에 주소(키)가 들어 있어 남기지 않고 버린다. 화면은 키를 직접 넣는 길을 보인다 + return false; + } + } +} diff --git a/flutter/lib/features/auth/data/models/two_factor_enrollment.dart b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart new file mode 100644 index 0000000..3be404a --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart @@ -0,0 +1,48 @@ +/// 2단계 인증 등록 — `POST /api/two-factor/enrollments` 의 응답 (계약 5절 "2단계 인증"). +/// +/// **비밀이다.** 키([secret])와 otpauth 주소([otpauthUrl])는 2단계 켜기 화면의 메모리에만 둔다 — +/// Riverpod 상태 · 로그 · 예외 문장에 두지 않고, 켜거나 화면을 떠나면 버린다. [toString] 도 값을 가린다. +class TwoFactorEnrollment { + const TwoFactorEnrollment({ + required this.enrollmentId, + required this.secret, + required this.otpauthUrl, + this.expiresAt, + }); + + /// 켜기 요청에 함께 보낸다 + final String enrollmentId; + + /// base32 키(32자) — [복사] · 직접 입력용 + final String secret; + + /// `otpauth://totp/…` — [인증 앱에 추가]가 이 주소로 인증 앱을 연다 + final String otpauthUrl; + + /// 이때까지 켜야 한다(10분) — 지나면 서버가 `AUTH_008` + final DateTime? expiresAt; + + /// 응답을 읽는다 — 칸이 비었거나 모양이 틀리면 null + static TwoFactorEnrollment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['enrollmentId']; + final Object? secret = json['secret']; + final Object? url = json['otpauthUrl']; + if (id is! String || id.isEmpty) return null; + if (secret is! String || secret.isEmpty) return null; + if (url is! String || !url.startsWith(kOtpauthUrlPrefix)) return null; + final Object? expires = json['expiresAt']; + return TwoFactorEnrollment( + enrollmentId: id, + secret: secret, + otpauthUrl: url, + expiresAt: expires is String ? DateTime.tryParse(expires)?.toUtc() : null, + ); + } + + @override + String toString() => 'TwoFactorEnrollment(***)'; +} + +/// otpauth 주소의 앞부분 — 다른 꼴의 주소로는 다른 앱을 열지 않는다 +const String kOtpauthUrlPrefix = 'otpauth://'; diff --git a/flutter/lib/features/auth/data/models/two_factor_status.dart b/flutter/lib/features/auth/data/models/two_factor_status.dart new file mode 100644 index 0000000..093fe39 --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_status.dart @@ -0,0 +1,17 @@ +/// 2단계 인증 상태 — `GET /api/two-factor` · 켜기 응답 (계약 5절 "2단계 인증" · 6절 `isRequired` · `deadline`). +/// +/// 앱은 로그인 틀에서만 쓴다: 새 비밀번호 단계가 코드 칸을 보일지(켠 직원은 비밀번호를 바꿀 때 코드가 든다), +/// 2단계 켜기 단계가 그사이 다른 곳에서 켰는지(`AUTH_008`) 본다. 계정 설정 화면은 웹에 있다. +class TwoFactorStatus { + const TwoFactorStatus({required this.isEnabled}); + + final bool isEnabled; + + /// 응답을 읽는다 — `isEnabled` 가 없으면 null + static TwoFactorStatus? tryParse(Object? json) { + if (json is! Map) return null; + final Object? enabled = json['isEnabled']; + if (enabled is! bool) return null; + return TwoFactorStatus(isEnabled: enabled); + } +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index bc16563..3b719ee 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:arasystem_app/core/api/api_config.dart'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; @@ -19,6 +20,23 @@ const Set kLoginInlineCodes = { 'VAL_001', }; +/// 코드 단계(코드를 실은 로그인)가 직접 보이는 실패 — 첫 단계 묶음에 시도 과다(`REQ_001` — 위쪽 상자)를 더한다. +/// 웹 코드 단계와 같다(웹 디자인 README 12단계 "로그인 — 코드 단계"). 첫 단계의 `REQ_001` 은 웹처럼 전역 알림이다 +const Set kLoginCodeInlineCodes = { + ...kLoginInlineCodes, + 'REQ_001', +}; + +/// 새 비밀번호 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 임시 비밀번호 칸 · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `REQ_001` 위쪽 상자 — 계약 1 · 5 · 6절, 웹과 같다) +const Set kPasswordChangeInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'VAL_001', + 'REQ_001', +}; + /// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다 const String kMalformedResponseCode = 'SYS_001'; @@ -47,7 +65,9 @@ class AuthRepository { 'otpCode': ?otpCode, }, // 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다) - options: ApiOptions.secret(silent: kLoginInlineCodes), + options: ApiOptions.secret( + silent: otpCode == null ? kLoginInlineCodes : kLoginCodeInlineCodes, + ), ); final Object? body = response.data; if (body is Map) { @@ -100,6 +120,28 @@ class AuthRepository { return info; } + /// 비밀번호를 바꾼다 — 새 비밀번호 단계(계약 1절 `PUT /api/auth/password` · 5절 `otpCode` · 6절 제한 풀기). + /// + /// [otpCode] 는 2단계 인증을 켠 직원만. 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비운 채 온다. + /// 서버가 엔진을 두 번 부르고 세션을 끊어 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + Future changePassword({ + required String currentPassword, + required String newPassword, + String? otpCode, + }) async { + await _dio.put( + ApiPaths.password, + data: { + 'currentPassword': currentPassword, + 'newPassword': newPassword, + 'otpCode': ?otpCode, + }, + options: ApiOptions.secret( + silent: kPasswordChangeInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + /// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나). /// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다. Future refresh() => _refresher.refresh(); diff --git a/flutter/lib/features/auth/data/repositories/support_contact_repository.dart b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart new file mode 100644 index 0000000..fe0849f --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart @@ -0,0 +1,26 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:dio/dio.dart'; + +/// 문의 연락처 — `GET /api/support-contact` (로그인 없음 · 계약 6절 "문의 연락처"). +/// +/// 운영 도구로 정하는 회사 설정값 한 줄이다(200자). 정하지 않았으면 null. 알림은 띄우지 않는다 — +/// 받지 못해도 화면은 지금 글 그대로다(웹과 같다). +class SupportContactRepository { + const SupportContactRepository(this._dio); + + final Dio _dio; + + /// 연락처 한 줄 — 정하지 않았거나 모양이 틀리면 null. 실패는 `DioException` + Future fetch() async { + final Response response = await _dio.get( + ApiPaths.supportContact, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + return data is Map + ? supportContactTextOf(data['text']) + : null; + } +} diff --git a/flutter/lib/features/auth/data/repositories/two_factor_repository.dart b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart new file mode 100644 index 0000000..b8e9457 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart @@ -0,0 +1,84 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:dio/dio.dart'; + +/// 2단계 켜기 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 지금 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `AUTH_008` 위쪽 상자와 새 키 · `REQ_001` 위쪽 상자 — 계약 5절 "2단계 인증", 웹과 같다). +/// `VAL_001` 은 넣지 않는다 — 코드는 보내기 전에 6자리를 보므로 서버의 `VAL_001` 은 지금 비밀번호 쪽이고, 웹처럼 전역 알림이다 +const Set kTwoFactorSetupInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'AUTH_008', + 'REQ_001', +}; + +/// 2단계 인증 API — 로그인 틀(새 비밀번호 · 2단계 켜기)이 쓰는 것만. 계약 5절 "2단계 인증" · 6절 "제한된 세션"(제한된 세션도 부를 수 있다). +/// +/// 등록의 키 · otpauth 주소는 받은 값을 돌려주기만 한다 — 저장하지 않는다(화면 메모리에만, `TwoFactorEnrollment`). +/// 실패는 늘 `DioException`(`error` 에 `ApiException`). 응답 모양이 틀리면 [kMalformedResponseCode] — 응답은 싣지 않는다. +class TwoFactorRepository { + const TwoFactorRepository(this._dio); + + final Dio _dio; + + /// 지금 상태 — 알림은 띄우지 않는다(화면이 못 받아도 다른 길로 안다: 서버의 `AUTH_007` 등) + Future status() async { + final Response response = await _dio.get( + ApiPaths.twoFactor, + options: ApiOptions.silent(), + ); + final TwoFactorStatus? status = TwoFactorStatus.tryParse(response.data); + if (status == null) throw _malformed(response); + return status; + } + + /// 등록을 시작한다 — 새 키(10분 안에 켠다). 직원마다 기다리는 등록은 하나라 앞의 것은 서버가 버린다. + /// + /// 응답에 키가 들어 있어 비밀 요청으로 보낸다(실패 예외의 응답 본문을 비운다). 알림은 띄우지 않는다 — + /// 화면이 키 자리에 사전 문구와 [다시 시도]를 보인다. + Future startEnrollment() async { + final Response response = await _dio.post( + ApiPaths.twoFactorEnrollments, + options: ApiOptions.secret(silent: true), + ); + final TwoFactorEnrollment? enrollment = TwoFactorEnrollment.tryParse( + response.data, + ); + // 받은 응답 객체에 키를 남기지 않는다 + response.data = null; + if (enrollment == null) throw _malformed(response); + return enrollment; + } + + /// 켠다 — 등록의 키로 만든 코드와 지금 비밀번호를 함께 보낸다. 비밀이 본문에 든다. + /// 서버가 엔진을 여러 번 부르므로 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + /// 성공 응답의 상태는 쓰지 않는다 — 다음 화면은 새 토큰의 세션 칸이 정한다. + Future activate({ + required String enrollmentId, + required String otpCode, + required String currentPassword, + }) async { + await _dio.post( + ApiPaths.twoFactorActivation, + data: { + 'enrollmentId': enrollmentId, + 'otpCode': otpCode, + 'currentPassword': currentPassword, + }, + options: ApiOptions.secret( + silent: kTwoFactorSetupInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + + /// 모양이 틀린 응답 — 응답을 싣지 않고 Bearer 를 지운 실패 + DioException _malformed(Response response) => withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); +} diff --git a/flutter/lib/features/auth/presentation/login_failures.dart b/flutter/lib/features/auth/presentation/login_failures.dart new file mode 100644 index 0000000..182ff17 --- /dev/null +++ b/flutter/lib/features/auth/presentation/login_failures.dart @@ -0,0 +1,28 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 틀이 칸 · 상자에 보이지 않는 실패를 전역 알림(스낵바)으로 보인다 — 웹의 "그 밖의 실패는 알림"과 같은 결. +/// +/// 서버가 답한 실패는 인터셉터가 이미 알렸다 — 같은 코드는 3초 안에 다시 띄우지 않아(`ErrorNotifier`) 겹치지 않는다. +/// 앱이 스스로 정한 실패(응답 모양이 틀림 · 받은 토큰을 보안 저장소에 두지 못함 — `SYS_001`)는 인터셉터를 지나지 않아 여기서만 알린다. +/// 문구는 사전에서만 온다(`ApiException.message`). 알리지 않는 것: `AUTH_010`(세션이 그 단계로 옮긴다) · +/// `AUTH_002`(세션 끝 — 로그인 화면의 위쪽 상자가 알린다, 인터셉터의 알림과 같은 규칙). +/// 부르기 전에 화면이 그 시도가 아직 지금 세션의 것인지(`SessionNotifier.attempt`) 본다 — 끝난 세션의 늦은 실패는 보이지 않는다. +void showLoginFailure(WidgetRef ref, Object error) { + final ApiException? apiError = switch (error) { + ApiException() => error, + DioException() => toApiException(error), + _ => null, + }; + if (apiError == null || + apiError.code == kRestrictedSessionCode || + apiError.code == kSessionExpiredCode) { + return; + } + ref.read(errorNotifierProvider).show(apiError); +} diff --git a/flutter/lib/features/auth/presentation/pages/login_code_page.dart b/flutter/lib/features/auth/presentation/pages/login_code_page.dart new file mode 100644 index 0000000..c66c545 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_code_page.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 둘째 단계 — 2단계 인증 코드(2단계를 켠 직원 — 앱 보드 `App1Login` 「2단계 인증 코드」 · 계약 5절 "로그인"). +/// +/// - 짙은 띠([다른 계정으로 로그인]) → 제목 · 설명 · 주소 → 코드 칸 → [확인] → 문의 줄 → 메일 프로그램 안내. +/// - 비밀번호는 세션 노티파이어 메모리에만 있다(10분) — 이 화면은 코드만 칸(화면 메모리)에 둔다. +/// - 6자리가 아니면 보내지 않고 칸 아래 `AUTH_007`. 서버의 `AUTH_006`(틀림 · 다시 쓴 코드)은 칸 아래 · 칸을 비운다. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). `AUTH_004`(그사이 비밀번호가 바뀜) · 10분 지남은 +/// 세션이 첫 단계로 옮긴다(첫 단계가 까닭을 보인다). 그 밖은 전역 알림. +/// - [다른 계정으로 로그인]은 보내는 중에도 누를 수 있다 — 아직 로그인 전이라 서버에 바뀌는 것이 없다. +/// 늦게 온 결과는 세션 노티파이어가 버리고(시도 세대 — 토큰이 실려 오면 그 세션을 끝낸다), 이 화면도 보이지 않는다. +class LoginCodePage extends ConsumerStatefulWidget { + const LoginCodePage({super.key}); + + @override + ConsumerState createState() => _LoginCodePageState(); +} + +class _LoginCodePageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + + /// 비밀번호를 확인한 주소 — 화면이 사라지는 동안(첫 단계로 옮겨 가는 중)에도 같은 글을 보이게 처음 값을 둔다 + late final String _email; + bool _submitting = false; + String? _codeError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + _email = session is CodeRequired ? session.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + _codeFocus.requestFocus(); + }); + } + + @override + void dispose() { + _code.clear(); + _code.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String code = _code.text; + if (!isCompleteOtp(code)) { + setState(() { + _alert = null; + _codeError = ErrorLexicon.getMessage('AUTH_007'); + }); + _codeFocus.requestFocus(); + return; + } + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + setState(() { + _submitting = true; + _codeError = null; + _alert = null; + }); + try { + await session.submitCode(code); + // 세션이 다음 화면(제한 단계 · 메일 · 시간이 지났으면 첫 단계)으로 옮긴다 — 쓴 코드는 칸에 남기지 않는다 + if (mounted) _code.clear(); + } on DioException catch (error) { + // 그사이 [다른 계정으로 로그인] · 첫 단계로 돌아갔으면(`AUTH_004`) 이 화면은 보이지 않는다 + if (!mounted || + session.attempt != attempt || + ref.read(sessionProvider).value is! CodeRequired) { + return; + } + _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + case 'VAL_001': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 같은 코드로 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + void _setCode(String _) { + // 고치면 칸 아래 오류를 내린다(다른 칸과 같다) + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final String? alert = _alert; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + LoginHeading( + title: ScreenTitles.loginCode, + subtitle: LoginCodeTexts.subtitle, + email: _email, + ), + if (alert != null) LoginAlertBox(message: alert), + LoginCodeField( + label: LoginCodeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _setCode, + readOnly: _submitting, + ), + LoginSubmitButton( + label: LoginCodeTexts.submit, + busyLabel: LoginCodeTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + ), + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: LoginCodeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/login_page.dart b/flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..4b76811 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 첫 단계 — 주소 · 비밀번호(앱 보드 `App1Login` 「주소와 비밀번호」). +/// +/// - 큰 브랜드 띠 → 제목 · 설명 → (위쪽 빨간 상자) → 메일 주소 · 비밀번호([보기]) → [로그인] → 문의 줄. +/// 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다 — 디자인 README). +/// - 비밀번호가 맞으면 세션이 다음 단계(코드 · 제한 단계 · 메일)로 옮긴다(라우터 redirect). 이 화면은 결과의 실패만 보인다. +/// - `AUTH_004` 는 위쪽 상자(사전 문구) + 두 칸 빨갛게 + 비밀번호 비우기, `VAL_001` 은 주소 칸 아래, 그 밖은 전역 알림. +/// 비운 칸도 보내지 않고 그 칸 아래에 `VAL_001` 사전 문구. +/// - 코드 단계에서 돌아왔으면(시간 지남 · 비밀번호 틀림) 주소를 남기고 위쪽 상자에 까닭을 보인다(세션 상태 `SignedOut.reason`), +/// 세션이 끝나 왔으면 `AUTH_002` 사전 문구. +/// - 비밀번호는 이 칸(화면 메모리)에만 있다 — 실패하면 비우고, 화면을 떠날 때 지운다. +/// 성공한 뒤 곧바로 비우지 않는 까닭: 다음 화면으로 넘어가며 이 칸 묶음(AutofillGroup)이 사라질 때 기기의 비밀번호 저장(자동 채우기)이 그 값을 본다. +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final TextEditingController _email = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _emailFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + bool _passwordShown = false; + bool _submitting = false; + + /// 위쪽 상자 — 사전 문구(`AUTH_004` · `AUTH_002`) 또는 코드 단계 시간 지남 + String? _notice; + + /// 주소 · 비밀번호가 맞지 않았다 — 두 칸을 빨갛게(글은 위쪽 상자) + bool _credentialsRejected = false; + String? _emailError; + String? _passwordError; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + if (session is SignedOut) _applyArrival(session); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + // 주소가 남아 있으면 비밀번호부터 넣는다 + if (_email.text.isNotEmpty) _passwordFocus.requestFocus(); + }); + } + + /// 이 화면으로 온 까닭 — 주소를 남기고 위쪽 상자에 보일 글 + void _applyArrival(SignedOut session) { + _email.text = session.email ?? ''; + switch (session.reason) { + case SignOutReason.codeTimedOut: + _notice = LoginTexts.codeTimedOut; + case SignOutReason.passwordRejected: + _notice = ErrorLexicon.getMessage('AUTH_004'); + _credentialsRejected = true; + case SignOutReason.expired: + _notice = ErrorLexicon.getMessage(kSessionExpiredCode); + case SignOutReason.none: + case SignOutReason.switchAccount: + break; + } + } + + @override + void dispose() { + _password.clear(); + _email.dispose(); + _password.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String email = _email.text.trim(); + final String password = _password.text; + final String? emailProblem = email.isEmpty + ? ErrorLexicon.getMessage('VAL_001') + : null; + final String? passwordProblem = + password.isEmpty || password.length > kLoginPasswordMaxLength + ? ErrorLexicon.getMessage('VAL_001') + : null; + setState(() { + _notice = null; + _credentialsRejected = false; + _emailError = emailProblem; + _passwordError = passwordProblem; + }); + if (emailProblem != null) { + _emailFocus.requestFocus(); + return; + } + if (passwordProblem != null) { + _passwordFocus.requestFocus(); + return; + } + + setState(() => _submitting = true); + try { + await ref + .read(sessionProvider.notifier) + .signIn(email: email, password: password); + // 세션이 다음 화면으로 옮긴다 + } on DioException catch (error) { + if (mounted) _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_004': + setState(() { + _notice = message; + _credentialsRejected = true; + }); + _password.clear(); + _passwordFocus.requestFocus(); + case 'VAL_001': + setState(() => _emailError = message); + _emailFocus.requestFocus(); + default: + showLoginFailure(ref, error); + } + } + + @override + Widget build(BuildContext context) { + final String? notice = _notice; + return LoginBrandFrame( + children: [ + const LoginHeading( + title: ScreenTitles.login, + subtitle: LoginTexts.subtitle, + ), + if (notice != null) LoginAlertBox(message: notice), + AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + LoginTextField( + label: LoginTexts.emailLabel, + controller: _email, + focusNode: _emailFocus, + placeholder: LoginTexts.emailPlaceholder, + error: _emailError, + invalid: _credentialsRejected, + // 위쪽 상자의 글을 칸에 이어 읽힌다(보드의 aria-describedby — 웹과 같이 두 칸 모두) + describedBy: notice, + keyboardType: TextInputType.emailAddress, + // 끄면 안드로이드 자판이 메일 주소 자판(@)이 아니게 된다 — 고쳐 쓰기(autocorrect)는 그대로 끈다 + enableSuggestions: true, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _passwordFocus.requestFocus(), + autofillHints: const [AutofillHints.username], + ), + LoginPasswordField( + label: LoginTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + placeholder: LoginTexts.passwordPlaceholder, + shown: _passwordShown, + onToggle: () => + setState(() => _passwordShown = !_passwordShown), + toggleLabel: LoginTexts.showPassword, + error: _passwordError, + invalid: _credentialsRejected, + describedBy: notice, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + autofillHints: const [AutofillHints.password], + ), + LoginSubmitButton( + label: LoginTexts.submit, + busyLabel: LoginTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + ), + LoginSupportLine( + question: SupportLineTexts.passwordQuestion, + fallback: SupportLineTexts.passwordFallback, + contact: ref.watch(supportContactProvider), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/password_change_page.dart b/flutter/lib/features/auth/presentation/pages/password_change_page.dart new file mode 100644 index 0000000..a01cfe1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/password_change_page.dart @@ -0,0 +1,436 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 셋째 단계 — 새 비밀번호 정하기(제한 `passwordChange` — 앱 보드 `App1Login` 「새 비밀번호 정하기」 · 계약 6절 "비밀번호 바꾸기 강제"). +/// +/// - 짙은 띠 → 제목 · 설명 · 주소 → (위쪽 상자) → (임시 비밀번호) · 새 비밀번호([보기]) · 확인 · (인증 앱 코드) → [비밀번호 바꾸고 계속] → 안내. +/// - 임시 비밀번호는 로그인 때 넣은 것을 세션 노티파이어가 쥐고 있다(10분) — 없을 때만(앱을 다시 열었다 · 10분 지남) 칸을 보인다. +/// - 코드 칸은 2단계 인증을 켠 직원만 — 코드 단계를 거쳐 왔거나 `GET /two-factor` 가 켜짐이거나 서버가 `AUTH_007` 로 코드를 요구했을 때(웹과 같다). +/// - 보내기 전에 서버와 같은 규칙을 본다(8자 이상 · 임시 비밀번호와 다름 · 확인 일치 · 128자). 서버 거절은 칸 아래에 — +/// `AUTH_005` 임시 비밀번호 칸(쥔 것이었으면 지우고 칸을 보인다) · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). 그 밖은 전역 알림. +/// - 바뀌면 칸을 비우고 세션 노티파이어가 새 토큰을 받아 다음 단계(2단계 켜기) · 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — +/// 그동안 칸 · 단추는 막는다(이미 바꿨다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다) — 서버가 이미 바꿨을 수 있는데 그 결과를 버리면 +/// 직원이 임시 비밀번호로 다시 들어가다 막힌다(`AUTH_004`). +class PasswordChangePage extends ConsumerStatefulWidget { + const PasswordChangePage({super.key}); + + @override + ConsumerState createState() => _PasswordChangePageState(); +} + +class _PasswordChangePageState extends ConsumerState { + final TextEditingController _temporary = TextEditingController(); + final TextEditingController _next = TextEditingController(); + final TextEditingController _confirm = TextEditingController(); + final TextEditingController _code = TextEditingController(); + final FocusNode _temporaryFocus = FocusNode(); + final FocusNode _nextFocus = FocusNode(); + final FocusNode _confirmFocus = FocusNode(); + final FocusNode _codeFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(임시 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 칸을 보인다 + late final ValueListenable _held; + late final String _email; + + /// 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원 + late final bool _passedCodeStep; + + /// `GET /two-factor` 가 켜짐이다 + bool _twoFactorOn = false; + + /// 서버가 코드를 요구했다(`AUTH_007` — 그사이 다른 곳에서 켬) + bool _codeRequired = false; + + bool _temporaryShown = false; + bool _nextShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 바꿨지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + PasswordChangeProblems _problems = const PasswordChangeProblems(); + String? _temporaryError; + String? _nextError; + String? _codeError; + + bool get _showCode => _passedCodeStep || _twoFactorOn || _codeRequired; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다) + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + _passedCodeStep = session.passedCodeStep; + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_held.value ? _nextFocus : _temporaryFocus).requestFocus(); + // 코드 단계를 거치지 않았으면(앱을 다시 열었다) 2단계 인증 상태를 묻는다 — 못 받아도 서버의 AUTH_007 이 칸을 부른다 + if (!_passedCodeStep) unawaited(_loadTwoFactorStatus()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + _clearFields(); + for (final TextEditingController controller in [ + _temporary, + _next, + _confirm, + _code, + ]) { + controller.dispose(); + } + for (final FocusNode node in [ + _temporaryFocus, + _nextFocus, + _confirmFocus, + _codeFocus, + ]) { + node.dispose(); + } + super.dispose(); + } + + /// 쥔 임시 비밀번호가 사라졌다(10분 · 서버 거절) — 칸을 보인다 + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + Future _loadTwoFactorStatus() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + try { + final TwoFactorStatus status = await ref + .read(twoFactorRepositoryProvider) + .status(); + if (!mounted || session.attempt != attempt) return; + if (status.isEnabled) setState(() => _twoFactorOn = true); + } on DioException { + // 못 받았다 — 서버가 AUTH_007 로 코드를 요구하면 그때 칸을 보인다 + } + } + + void _clearFields() { + _temporary.clear(); + _next.clear(); + _confirm.clear(); + _code.clear(); + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + Future _submit() async { + if (_blocked) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ({String email, String password})? held = session.heldLogin; + final PasswordChangeProblems problems = passwordChangeProblems( + current: held?.password ?? _temporary.text, + next: _next.text, + confirm: _confirm.text, + ); + final bool codeMissing = _showCode && !isCompleteOtp(_code.text); + setState(() { + _alert = null; + _problems = problems; + _temporaryError = null; + _nextError = problems.next == NewPasswordProblem.tooLong + ? ErrorLexicon.getMessage('VAL_001') + : null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + }); + if (problems.hasAny) { + // 10분이 막 지나 임시 비밀번호 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater( + problems.currentMissing + ? _temporaryFocus + : problems.next != null + ? _nextFocus + : _confirmFocus, + ); + return; + } + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.changePassword( + newPassword: _next.text, + currentPassword: held == null ? _temporary.text : null, + otpCode: _showCode ? _code.text : null, + ); + } on DioException catch (error) { + if (mounted && session.attempt == attempt) _onFailure(error); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + // 쥔 비밀번호가 그사이 사라졌다 — 칸을 보이고 안내 글을 빨갛게 + setState( + () => _problems = const PasswordChangeProblems(currentMissing: true), + ); + _focusLater(_temporaryFocus); + return; + case RestrictedStepResult.done: + setState(() { + _clearFields(); + _problems = const PasswordChangeProblems(); + }); + } + await _finish(); + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — 임시 비밀번호 칸이 이 오류와 함께 보인다 + setState(() => _temporaryError = message); + _focusLater(_temporaryFocus); + case 'VAL_001': + setState(() => _nextError = message); + _nextFocus.requestFocus(); + case 'AUTH_006': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'AUTH_007': + // 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보인다 + setState(() { + _codeRequired = true; + _codeError = message; + }); + _code.clear(); + _focusLater(_codeFocus); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + /// 바꾼 뒤 — 새 토큰을 받아 다음 단계로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(PasswordChangeTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + // 칸을 고치면 그 칸에 걸린 오류를 내린다(임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다 — 웹과 같다) + void _onTemporaryChanged(String _) { + if (!_problems.currentMissing && + _temporaryError == null && + _problems.next != NewPasswordProblem.rule) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + next: _problems.next == NewPasswordProblem.rule ? null : _problems.next, + confirmMismatch: _problems.confirmMismatch, + ); + _temporaryError = null; + }); + } + + void _onNextChanged(String _) { + if (_problems.next == null && + !_problems.confirmMismatch && + _nextError == null) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + ); + _nextError = null; + }); + } + + void _onConfirmChanged(String _) { + if (!_problems.confirmMismatch) return; + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + next: _problems.next, + ); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final bool askTemporary = !_held.value; + final String? finishError = _finishError; + final String? alert = _alert; + final bool showCode = _showCode; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.passwordChange, + subtitle: PasswordChangeTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish()), + ) + else if (alert != null) + LoginAlertBox(message: alert), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + if (askTemporary) + LoginPasswordField( + label: PasswordChangeTexts.temporaryLabel, + controller: _temporary, + focusNode: _temporaryFocus, + shown: _temporaryShown, + onToggle: () => + setState(() => _temporaryShown = !_temporaryShown), + toggleLabel: PasswordChangeTexts.showTemporary, + hint: PasswordChangeTexts.temporaryHint, + hintIsError: _problems.currentMissing, + error: _temporaryError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _nextFocus.requestFocus(), + onChanged: _onTemporaryChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + LoginPasswordField( + label: PasswordChangeTexts.nextLabel, + controller: _next, + focusNode: _nextFocus, + shown: _nextShown, + onToggle: () => setState(() => _nextShown = !_nextShown), + toggleLabel: PasswordChangeTexts.showNext, + hint: PasswordChangeTexts.nextHint, + hintIsError: _problems.next == NewPasswordProblem.rule, + error: _nextError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _confirmFocus.requestFocus(), + onChanged: _onNextChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + LoginTextField( + label: PasswordChangeTexts.confirmLabel, + controller: _confirm, + focusNode: _confirmFocus, + // 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + obscureText: !_nextShown, + keyboardType: TextInputType.visiblePassword, + error: _problems.confirmMismatch + ? PasswordChangeTexts.mismatch + : null, + textInputAction: showCode + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => + showCode ? _codeFocus.requestFocus() : unawaited(_submit()), + onChanged: _onConfirmChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + if (showCode) + LoginCodeField( + label: PasswordChangeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + hint: PasswordChangeTexts.codeHint, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + LoginSubmitButton( + label: PasswordChangeTexts.submit, + busyLabel: PasswordChangeTexts.submitting, + busy: _busy, + enabled: finishError == null, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + const LoginNote(text: PasswordChangeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart new file mode 100644 index 0000000..2083254 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 모르는 제한(서버가 새 제한을 더했을 때) — `AUTH_010` 사전 문구와 [다른 계정으로 로그인]만(웹과 같다 — 다시 받기 · 다시 보내기를 되풀이하지 않는다). +/// +/// 제목이 없는 화면이라 사전 문구 상자가 화면 이름이다(단계가 바뀌면 화면 낭독기가 읽는다). +class RestrictedLoginPage extends ConsumerWidget { + const RestrictedLoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final SessionState? state = ref.watch(sessionProvider).value; + final String? email = state is SignedIn ? state.info.email : null; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + if (email != null) LoginAccountChip(email: email), + LoginAlertBox( + message: ErrorLexicon.getMessage(kRestrictedSessionCode), + namesRoute: true, + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart new file mode 100644 index 0000000..0685756 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart @@ -0,0 +1,633 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 키를 받는 상태 +enum _EnrollmentLoad { loading, ready, failed } + +/// 로그인 넷째 단계 — 2단계 인증 켜기(제한 `twoFactorSetup` — 앱 보드 `App1Login` 「2단계 인증 켜기」 · 계약 6절 "2단계 인증 필수"). +/// +/// - 같은 휴대폰이라 QR 대신 ① [인증 앱에 추가](otpauth 주소로 인증 앱을 바로 연다) · 키와 [복사] ② 코드 칸 (③ 지금 비밀번호 — 쥐고 있지 않을 때만). +/// - 키는 이 화면을 열 때마다 새로 받는다(`POST /two-factor/enrollments` — 10분 안에 켠다). **키 · otpauth 주소는 이 화면의 메모리에만** 두고 +/// 켜거나 떠나면 버린다(Riverpod 상태 · 로그에 두지 않는다). [복사]한 키는 클립보드에 남는다 — 앱이 지우지 않는다. +/// - 인증 앱을 찾지 못하면(열 앱 없음) ① 아래에 안내 글 — 키를 직접 넣는 길이 바로 아래에 있다. +/// - 서버 거절: `AUTH_006` 코드 칸(비우고 초점 — 키는 그대로), `AUTH_005` 지금 비밀번호 칸(쥔 것이었으면 지우고 ③ 을 보인다), +/// `AUTH_008`(시간 지남 · 그사이 바뀜) 위쪽 상자 + 새 키(다른 곳에서 이미 켰으면 이 단계를 건너뛴다), 시도 과다(`REQ_001`) 위쪽 상자. +/// 그 밖은 전역 알림 — 서버의 `VAL_001` 도(코드는 보내기 전에 6자리를 보므로 지금 비밀번호 쪽이다 · 맞는 코드는 지우지 않는다, 웹과 같다). +/// - 켜면 세션 노티파이어가 새 토큰을 받아 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — 그동안 칸 · 단추는 막는다(이미 켰다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다 — 서버가 이미 켰을 수 있다). +class TwoFactorSetupPage extends ConsumerStatefulWidget { + const TwoFactorSetupPage({super.key}); + + @override + ConsumerState createState() => _TwoFactorSetupPageState(); +} + +class _TwoFactorSetupPageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(또는 방금 정한 새 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 ③ 칸 + late final ValueListenable _held; + late final String _email; + + /// 등록 — 키 · otpauth 주소(비밀). 이 화면의 메모리에만 + TwoFactorEnrollment? _enrollment; + _EnrollmentLoad _load = _EnrollmentLoad.loading; + + /// 키를 받지 못한 까닭 — 사전 문구 + String? _loadError; + + /// 키를 받는 차례 — 다시 받으면 앞의 응답은 버린다 + int _loadSeq = 0; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했다 + bool _openFailed = false; + bool _passwordShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 위쪽 상자 — 처음부터 다시(`AUTH_008`) · 시도 과다(`REQ_001`) 사전 문구. 다음에 보낼 때 내린다 + String? _alert; + + /// 켰지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + String? _codeError; + String? _passwordError; + + /// ③ 을 비우고 보냈다 — 안내 글이 빨간 오류가 된다 + bool _passwordMissing = false; + + /// 켰다(또는 다른 곳에서 이미 켰다) — 키 · 칸은 쓸 일이 없다. 넘기지 못했으면 위쪽 상자와 [다시 시도]만 남긴다 + bool _enabled = false; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다 + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + unawaited(_loadEnrollment()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + // 비밀을 버린다 — 키 · otpauth 주소 · 코드 · 비밀번호 + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + _code.dispose(); + _password.dispose(); + _codeFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + /// 이 화면이 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지 + bool _stillHere(int attempt) => + mounted && ref.read(sessionProvider.notifier).attempt == attempt; + + Future _loadEnrollment() async { + final int seq = ++_loadSeq; + final int attempt = ref.read(sessionProvider.notifier).attempt; + setState(() { + _enrollment = null; + _load = _EnrollmentLoad.loading; + _loadError = null; + _openFailed = false; + }); + try { + final TwoFactorEnrollment enrollment = await ref + .read(twoFactorRepositoryProvider) + .startEnrollment(); + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _enrollment = enrollment; + _load = _EnrollmentLoad.ready; + }); + } on DioException catch (error) { + if (seq != _loadSeq || !_stillHere(attempt)) return; + // 이미 켜져 있다 — 다른 곳에서 켰다 + if (apiErrorCodeOf(error) == 'AUTH_008' && + await _skipIfEnabledElsewhere(attempt)) { + return; + } + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _load = _EnrollmentLoad.failed; + _loadError = toApiException(error).message; + }); + } + } + + /// 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true) + Future _skipIfEnabledElsewhere(int attempt) async { + final TwoFactorStatus status; + try { + status = await ref.read(twoFactorRepositoryProvider).status(); + } on DioException { + return false; + } + if (!status.isEnabled) return false; + if (_stillHere(attempt)) { + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + await _finish(announce: false); + } + return true; + } + + Future _openAuthenticator() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final bool opened = await ref + .read(authenticatorLauncherProvider) + .open(enrollment.otpauthUrl); + if (mounted) setState(() => _openFailed = !opened); + } + + Future _copyKey() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + bool copied = true; + try { + // 키를 띄어쓰기 없이 — 클립보드에 남는다(앱이 지우지 않는다) + await Clipboard.setData(ClipboardData(text: enrollment.secret)); + } on PlatformException { + copied = false; + } + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + copied + ? TwoFactorSetupTexts.copied + : TwoFactorSetupTexts.copyFailed, + ), + ), + ); + } + + Future _submit() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (_blocked || enrollment == null) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final bool held = session.heldLogin != null; + final bool codeMissing = !isCompleteOtp(_code.text); + final bool passwordMissing = !held && _password.text.isEmpty; + setState(() { + _alert = null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + _passwordError = null; + _passwordMissing = passwordMissing; + }); + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + if (passwordMissing) { + // 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater(_passwordFocus); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.enableTwoFactor( + enrollmentId: enrollment.enrollmentId, + otpCode: _code.text, + currentPassword: held ? null : _password.text, + ); + } on DioException catch (error) { + if (_stillHere(attempt)) await _onFailure(error, attempt); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + setState(() => _passwordMissing = true); + _focusLater(_passwordFocus); + return; + case RestrictedStepResult.done: + // 켰다 — 다시 쓸 일이 없는 비밀(키 · 코드 · 비밀번호)을 버린다 + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + } + await _finish(announce: true); + } + + Future _onFailure(DioException error, int attempt) async { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추 · 키는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — ③ 칸이 이 오류와 함께 보인다 + setState(() => _passwordError = message); + _focusLater(_passwordFocus); + case 'AUTH_008': + if (await _skipIfEnabledElsewhere(attempt)) return; + if (!_stillHere(attempt)) return; + setState(() { + _alert = message; + _codeError = null; + _passwordError = null; + }); + _code.clear(); + await _loadEnrollment(); + default: + showLoginFailure(ref, error); + } + } + + /// 켠 뒤 — 새 토큰을 받아 메일로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish({required bool announce}) async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (announce && result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(TwoFactorSetupTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + void _onPasswordChanged(String _) { + if (_passwordError != null || _passwordMissing) { + setState(() { + _passwordError = null; + _passwordMissing = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle? stepTitle = theme.textTheme.titleSmall; + // 켠 뒤에는 쥔 비밀번호를 지웠다 — ③ 을 다시 보이지 않는다 + final bool askPassword = !_held.value && !_enabled; + final String? finishError = _finishError; + final String? alert = _alert; + // 켰지만 넘기지 못했다 — 키 · 칸 · 단추는 쓸 일이 없어 위쪽 상자와 [다시 시도]만 남긴다 + final bool onlyRetry = _enabled && finishError != null; + return LoginStepFrame( + topPadding: AppSpacing.s24, + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.twoFactorSetup, + subtitle: TwoFactorSetupTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish(announce: true)), + ) + else if (alert != null) + LoginAlertBox(message: alert), + if (!onlyRetry) ...[ + _steps(stepTitle, askPassword), + LoginSubmitButton( + label: TwoFactorSetupTexts.submit, + busyLabel: TwoFactorSetupTexts.submitting, + busy: _busy, + enabled: _load == _EnrollmentLoad.ready && finishError == null, + onPressed: () => unawaited(_submit()), + ), + ], + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: TwoFactorSetupTexts.note), + ], + ); + } + + /// 차례 ① 인증 앱에 추가 · ② 코드 · (③ 지금 비밀번호) + Widget _steps(TextStyle? stepTitle, bool askPassword) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s22, + children: [ + LoginStepItem( + number: 1, + child: _AddToAuthenticator( + email: _email, + enrollment: _enrollment, + load: _load, + loadError: _loadError, + openFailed: _openFailed, + enabled: !_blocked, + onOpen: () => unawaited(_openAuthenticator()), + onCopy: () => unawaited(_copyKey()), + onRetry: () => unawaited(_loadEnrollment()), + ), + ), + LoginStepItem( + number: 2, + child: LoginCodeField( + label: TwoFactorSetupTexts.codeLabel, + labelStyle: stepTitle, + description: TwoFactorSetupTexts.codeHint, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: askPassword + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => askPassword + ? _passwordFocus.requestFocus() + : unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + ), + if (askPassword) + LoginStepItem( + number: 3, + child: LoginPasswordField( + label: TwoFactorSetupTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + shown: _passwordShown, + onToggle: () => setState(() => _passwordShown = !_passwordShown), + toggleLabel: TwoFactorSetupTexts.showPassword, + hint: TwoFactorSetupTexts.passwordHint, + hintIsError: _passwordMissing, + error: _passwordError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onPasswordChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + ), + ], + ); + } +} + +/// ① 인증 앱에 추가 — [인증 앱에 추가] · (찾지 못함 안내) · 키와 [복사] · 저장될 이름. 키를 받는 동안은 회색 자리, 못 받으면 사전 문구 + [다시 시도] +class _AddToAuthenticator extends StatelessWidget { + const _AddToAuthenticator({ + required this.email, + required this.enrollment, + required this.load, + required this.loadError, + required this.openFailed, + required this.enabled, + required this.onOpen, + required this.onCopy, + required this.onRetry, + }); + + final String email; + final TwoFactorEnrollment? enrollment; + final _EnrollmentLoad load; + final String? loadError; + final bool openFailed; + + /// 켜는 중 · 이미 켰으면 누를 수 없다 + final bool enabled; + final VoidCallback onOpen; + final VoidCallback onCopy; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? error = loadError; + final bool ready = enrollment != null && enabled; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s10, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s4, + children: [ + Semantics( + header: true, + child: KeepAllText( + TwoFactorSetupTexts.addTitle, + style: theme.textTheme.titleSmall, + ), + ), + KeepAllText( + TwoFactorSetupTexts.addHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (load == _EnrollmentLoad.failed && error != null) + LoginAlertBox( + message: error, + actionLabel: TwoFactorSetupTexts.retry, + onAction: onRetry, + ) + else ...[ + OutlinedButton.icon( + onPressed: ready ? onOpen : null, + icon: const Icon(Icons.open_in_new, size: AppSize.iconSmall), + label: const Text(TwoFactorSetupTexts.addButton), + ), + if (openFailed) + const LoginFieldMessage( + text: TwoFactorSetupTexts.addFailed, + isError: true, + ), + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + TwoFactorSetupTexts.keyLabel, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + _KeyBox(enrollment: enrollment, onCopy: ready ? onCopy : null), + KeepAllText( + TwoFactorSetupTexts.savedAs(email), + style: theme.textTheme.labelSmall?.copyWith( + color: extra.textMuted, + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// 키 상자 — 4자씩 · 두 줄 · 고정폭(보기 위한 띄어쓰기) + [복사](키를 띄어쓰기 없이). 받는 동안은 회색 자리 +class _KeyBox extends StatelessWidget { + const _KeyBox({required this.enrollment, required this.onCopy}); + + final TwoFactorEnrollment? enrollment; + final VoidCallback? onCopy; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final TextStyle? base = theme.textTheme.bodyMedium; + final double fontSize = base?.fontSize ?? AppTypography.bodyMedium; + final TextStyle keyStyle = TextStyle( + fontFamily: AppTypography.monospaceFamily, + fontSize: fontSize, + height: AppTypography.lineHeightRelaxed, + letterSpacing: fontSize * AppTypography.letterSpacingKeyEm, + color: scheme.onSurface, + ); + final TwoFactorEnrollment? current = enrollment; + final Widget key = current == null + // 키를 받는 중 — 두 줄 높이의 회색 자리 + ? Semantics( + label: TwoFactorSetupTexts.keyLoading, + child: Container( + height: fontSize * AppTypography.lineHeightRelaxed * 2, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ) + : Text(keyLines(current.secret).join('\n'), style: keyStyle); + return Container( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s2, + AppSpacing.s4, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainer, + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Row( + spacing: AppSpacing.s4, + children: [ + Expanded(child: key), + TextButton.icon( + onPressed: onCopy, + icon: const Icon( + Icons.content_copy_outlined, + size: AppSize.iconSmall, + ), + label: const Text( + TwoFactorSetupTexts.copy, + semanticsLabel: TwoFactorSetupTexts.copyLabel, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_providers.dart b/flutter/lib/features/auth/presentation/providers/login_providers.dart new file mode 100644 index 0000000..2592a82 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/login_providers.dart @@ -0,0 +1,62 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 2단계 인증 API — 로그인 틀의 제한 단계가 쓴다 +final Provider twoFactorRepositoryProvider = + Provider( + (Ref ref) => TwoFactorRepository(ref.watch(dioProvider)), + ); + +/// 문의 연락처 API +final Provider supportContactRepositoryProvider = + Provider( + (Ref ref) => SupportContactRepository(ref.watch(dioProvider)), + ); + +/// [인증 앱에 추가] — 시험은 가짜로 덮어쓴다 +final Provider authenticatorLauncherProvider = + Provider((Ref ref) => const AuthenticatorLauncher()); + +/// 문의 연락처 한 줄 — 로그인 첫 화면 · 코드 단계 · 2단계 켜기의 문의 줄. 정하지 않았거나 받지 못했으면 null(화면은 지금 글). +/// +/// 화면이 [SupportContactNotifier.ensureLoaded] 를 부른다. 한 번 받으면 앱이 떠 있는 동안 다시 묻지 않고(운영 도구로 바꾸면 다음 실행에 반영), +/// 받지 못했으면 다음에 화면을 열 때 다시 묻는다(웹 `support-contact.store` 와 같다). 회사 설정값이라 로그아웃 때 지우지 않는다. +final NotifierProvider supportContactProvider = + NotifierProvider( + SupportContactNotifier.new, + retry: noAutomaticRetry, + ); + +class SupportContactNotifier extends Notifier { + Future? _loading; + bool _loaded = false; + + @override + String? build() => null; + + /// 아직 받지 않았으면 받는다 — 몇 번을 불러도 요청은 하나 + Future ensureLoaded() { + if (_loaded) return Future.value(); + return _loading ??= _load(); + } + + Future _load() async { + try { + final String? text = await ref + .read(supportContactRepositoryProvider) + .fetch(); + if (!ref.mounted) return; + state = text; + _loaded = true; + } on DioException { + // 받지 못했다 — 지금 글 그대로(다음에 화면을 열 때 다시 묻는다) + } finally { + _loading = null; + } + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_secret.dart b/flutter/lib/features/auth/presentation/providers/login_secret.dart index cbf5ea8..9de70bd 100644 --- a/flutter/lib/features/auth/presentation/providers/login_secret.dart +++ b/flutter/lib/features/auth/presentation/providers/login_secret.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:flutter/foundation.dart'; /// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계) const Duration kLoginSecretLifetime = Duration(minutes: 10); /// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다. /// -/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다). -/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분. +/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다) · 2단계 켜기(지금 비밀번호 — 새 비밀번호를 정했으면 그것). +/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 거절함(`AUTH_005`) · 서버가 마지막으로 확인하고 10분. /// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다. +/// 쥐고 있는지([held] — 참 · 거짓 하나, 비밀이 아니다)는 화면이 듣는다: 없으면 임시 · 지금 비밀번호 칸을 보인다. class LoginSecret { LoginSecret({ Clock clock = systemClock, @@ -19,6 +21,7 @@ class LoginSecret { final Clock _clock; final Duration _lifetime; + final ValueNotifier _held = ValueNotifier(false); String? _email; String? _password; @@ -27,10 +30,11 @@ class LoginSecret { /// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다 void hold(String email, String password) { - clear(); + _forget(); _email = email; _password = password; renew(); + _held.value = true; } /// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다 @@ -57,7 +61,15 @@ class LoginSecret { /// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다) bool get isHeld => _password != null; + /// 쥐고 있는지가 바뀔 때 알린다 — 화면이 칸을 보일지 정한다(값은 참 · 거짓뿐) + ValueListenable get held => _held; + void clear() { + _forget(); + _held.value = false; + } + + void _forget() { _timer?.cancel(); _timer = null; _email = null; diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index a13f072..8ddd681 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/session_events.dart'; @@ -13,9 +14,12 @@ import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -38,18 +42,31 @@ final AsyncNotifierProvider sessionProvider = retry: noAutomaticRetry, ); -/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기. +/// 제한 단계(새 비밀번호 · 2단계 켜기)의 일이 어떻게 끝났나 — 실패는 `DioException` 으로 던진다 +enum RestrictedStepResult { + /// 마쳤다 — 세션이 그대로다 + done, + + /// 지금 비밀번호가 없다(로그인 때 넣은 것을 쥐고 있지 않다 — 10분 지남 · 앱을 다시 열었다). 보내지 않았다 — 화면이 칸을 보인다 + needsPassword, + + /// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않았다 + stale, +} + +/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기) · 로그아웃 · 세션 끝 · 제한 다시 맞추기. /// /// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다. /// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다. /// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다. -/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. -/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의 -/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). +/// 비밀이 든 요청(로그인 · 코드 · 비밀번호 바꾸기 · 2단계 켜기)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. +/// - **시도 세대**([_epoch] — 화면은 [attempt]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · +/// 제한 단계 · 제한 다시 맞추기의 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). class SessionNotifier extends AsyncNotifier { LoginSecret? _secretHolder; Future? _rechecking; int _epoch = 0; + bool _passedCodeStep = false; /// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다 final List _pendingRechecks = []; @@ -59,6 +76,8 @@ class SessionNotifier extends AsyncNotifier { AuthRepository get _repository => ref.read(authRepositoryProvider); + TwoFactorRepository get _twoFactor => ref.read(twoFactorRepositoryProvider); + TokenStore get _store => ref.read(tokenStoreProvider); TokenRefresher get _refresher => ref.read(tokenRefresherProvider); @@ -82,9 +101,18 @@ class SessionNotifier extends AsyncNotifier { return _restore(); } - /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) + /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 제한 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) ({String email, String password})? get heldLogin => _secret.read(); + /// 로그인 때 넣은 비밀번호를 쥐고 있는지 — 제한 단계 화면이 듣고, 없으면 임시 · 지금 비밀번호 칸을 보인다(값은 참 · 거짓뿐) + ValueListenable get heldLoginListenable => _secret.held; + + /// 시도 세대 — 화면이 요청 전에 읽어 두고, 끝난 뒤 다르면(그사이 로그아웃 · 다른 계정 · 세션 끝) 결과를 보이지 않는다 + int get attempt => _epoch; + + /// 이번 로그인이 코드 단계를 거쳤다 — 2단계 인증을 켠 직원이라 새 비밀번호 단계가 처음부터 코드 칸을 보인다(계약 6절) + bool get passedCodeStep => _passedCodeStep; + /// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 void retry() { _refresher.forgetFailure(); @@ -94,6 +122,7 @@ class SessionNotifier extends AsyncNotifier { /// 비밀번호 단계 Future signIn({required String email, required String password}) async { final int epoch = _epoch; + _passedCodeStep = false; final AppLoginResult result = await _repository.login( email: email, password: password, @@ -103,12 +132,18 @@ class SessionNotifier extends AsyncNotifier { /// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절). /// - /// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다. + /// - 10분이 지났으면 보내지 않고 첫 단계로 돌아간다(주소는 남긴다). + /// - `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다 — 첫 단계가 위쪽 상자에 보인다(주소는 남긴다). + /// - 코드를 실었는데도 서버가 코드를 묻으면(`twoFactorRequired`) 받아들이지 않은 코드로 보고 `AUTH_006` 으로 던진다(웹과 같다). Future submitCode(String otpCode) async { + final SessionState? current = state.value; final ({String email, String password})? held = _secret.read(); if (held == null) { - state = const AsyncData( - SignedOut(reason: SignOutReason.codeTimedOut), + state = AsyncData( + SignedOut( + reason: SignOutReason.codeTimedOut, + email: current is CodeRequired ? current.email : null, + ), ); return; } @@ -126,18 +161,150 @@ class SessionNotifier extends AsyncNotifier { ref.mounted && apiErrorCodeOf(error) == 'AUTH_004') { _secret.clear(); - state = const AsyncData(SignedOut()); + state = AsyncData( + SignedOut(reason: SignOutReason.passwordRejected, email: held.email), + ); } rethrow; } + if (result.status == LoginStatus.twoFactorRequired) { + if (!ref.mounted || epoch != _epoch) return; + throw apiFailure(RequestOptions(path: ApiPaths.appLogin), 'AUTH_006'); + } await _apply( result, epoch: epoch, email: held.email, password: held.password, + viaCode: true, ); } + /// 새 비밀번호 단계(제한 `passwordChange` — 계약 6절 "비밀번호 바꾸기 강제") — `PUT /api/auth/password`. + /// + /// [currentPassword] 가 null 이면 로그인 때 쥔 비밀번호(임시 비밀번호)를 쓴다 — 쥐고 있지 않으면 보내지 않고 [RestrictedStepResult.needsPassword]. + /// [otpCode] 는 2단계 인증을 켠 직원만(로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 다음 코드). + /// - 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다 — 화면이 임시 비밀번호 칸을 그 오류와 함께 보인다. + /// - 바뀌면 새 비밀번호를 쥔다(다음 단계 2단계 켜기의 지금 비밀번호 — 서버가 방금 확인했으니 10분을 새로 센다). + /// 다음 화면으로 옮기는 일은 [finishRestrictedStep] 이 한다. + Future changePassword({ + required String newPassword, + String? currentPassword, + String? otpCode, + }) async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _repository.changePassword( + currentPassword: password, + newPassword: newPassword, + otpCode: otpCode, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.hold(current.info.email, newPassword); + return RestrictedStepResult.done; + } + + /// 2단계 켜기 단계(제한 `twoFactorSetup` — 계약 6절 "2단계 인증 필수") — `POST /api/two-factor/activation`. + /// + /// [currentPassword] 가 null 이면 쥔 비밀번호(로그인 때 넣은 것 — 방금 새 비밀번호를 정했으면 그것)를 쓴다 — 없으면 [RestrictedStepResult.needsPassword]. + /// 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다. 켜면 쥔 비밀번호를 지운다(다시 쓸 일이 없다). 다음 화면은 [finishRestrictedStep]. + Future enableTwoFactor({ + required String enrollmentId, + required String otpCode, + String? currentPassword, + }) async { + final int epoch = _epoch; + if (state.value is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _twoFactor.activate( + enrollmentId: enrollmentId, + otpCode: otpCode, + currentPassword: password, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.clear(); + return RestrictedStepResult.done; + } + + /// 제한 단계를 마친 뒤 — 새 토큰을 받아 다음 제한(있으면 그 단계) · 제한 없음(메일)으로 옮긴다 + /// (계약 6절 "제한된 세션" — 푸는 요청이 성공하면 곧바로 갱신. 갱신도 한 번에 하나인 [TokenRefresher] 를 지난다). + /// + /// - 갱신이 일시 장애면 세션 확인(me — 세션 표의 지금 값)으로 정한다(웹과 같다). 둘 다 받지 못하면 갱신의 실패를 `DioException` 으로 던진다 — + /// 화면은 그 단계에 머물며 위쪽 상자에 사전 문구와 [다시 시도](이것을 다시 부른다). + /// - 갱신이 세션을 끝냈거나(401 · 유예 응답) 그사이 로그아웃했으면 [RestrictedStepResult.stale] — 세션 끝 흐름이 로그인 화면으로 옮긴다. + /// - 방금의 갱신 실패를 다시 쓰지 않는다 — [다시 시도]는 사용자가 누른 것이라 새로 묻는다(되살리기의 [retry] 와 같다). + Future finishRestrictedStep() async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + bool isCurrent() => + epoch == _epoch && ref.mounted && state.value is SignedIn; + + _refresher.forgetFailure(); + final RefreshOutcome outcome = await _repository.refresh(); + if (!isCurrent()) return RestrictedStepResult.stale; + ApiException? refreshError; + switch (outcome) { + case RefreshSucceeded(:final Map? body): + final AuthSessionInfo? info = AuthSessionInfo.tryParse(body); + if (info != null && info.email == current.info.email) { + _setSignedIn(info); + return RestrictedStepResult.done; + } + // 세션 칸이 없다(그사이 다른 요청이 먼저 받아 두었다) — 아래 세션 확인으로 정한다 + case RefreshRejected(): + case RefreshDiscarded(): + return RestrictedStepResult.stale; + case RefreshFailed(:final ApiException error): + refreshError = error; + } + + final AuthSessionInfo info; + try { + info = await _repository.readSessionDirect(); + } on DioException { + if (!isCurrent()) return RestrictedStepResult.stale; + if (refreshError == null) rethrow; + throw DioException( + requestOptions: RequestOptions(path: ApiPaths.appRefresh), + error: refreshError, + ); + } + if (!isCurrent()) return RestrictedStepResult.stale; + _setSignedIn(info); + return RestrictedStepResult.done; + } + /// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고, /// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다. Future switchAccount() async { @@ -195,6 +362,7 @@ class SessionNotifier extends AsyncNotifier { /// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게) void _advanceEpoch() { _epoch++; + _passedCodeStep = false; _rechecking = null; final List waiting = List.of( _pendingRechecks, @@ -277,6 +445,7 @@ class SessionNotifier extends AsyncNotifier { required int epoch, required String email, required String password, + bool viaCode = false, }) async { if (!ref.mounted || epoch != _epoch) { // 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다 @@ -311,15 +480,17 @@ class SessionNotifier extends AsyncNotifier { return; } final AuthSessionInfo info = result.session!; - if (info.restriction != null) { - // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다 + _passedCodeStep = viaCode; + if (_usesHeldPassword(info.restriction)) { + // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다. + // 모르는 제한은 쓸 단계가 없어 쥐지 않는다(웹 LoginPage 의 onSignedIn 과 같다) _secret.hold(email, password); } _setSignedIn(info); } } - /// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 + /// 로그인한 상태로 둔다 — 제한이 풀렸거나 모르는 제한이 됐으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 void _setSignedIn(AuthSessionInfo info) { final SessionState? previous = state.value; final bool wasReady = @@ -329,12 +500,18 @@ class SessionNotifier extends AsyncNotifier { } void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) { + // 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다 + // (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn) + if (!_usesHeldPassword(info.restriction)) _secret.clear(); if (info.restriction != null) return; - // 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일") - _secret.clear(); if (!wasReady) unawaited(_registerPush()); } + /// 로그인 때 넣은 비밀번호를 지금 비밀번호로 쓰는 제한 단계인가 — 새 비밀번호 · 2단계 켜기만 + static bool _usesHeldPassword(SessionRestriction? restriction) => + restriction == SessionRestriction.passwordChange || + restriction == SessionRestriction.twoFactorSetup; + Future _registerPush() async { try { await _push.register(); diff --git a/flutter/lib/features/auth/presentation/providers/session_state.dart b/flutter/lib/features/auth/presentation/providers/session_state.dart index 050244e..b9f07e1 100644 --- a/flutter/lib/features/auth/presentation/providers/session_state.dart +++ b/flutter/lib/features/auth/presentation/providers/session_state.dart @@ -9,17 +9,20 @@ sealed class SessionState { /// 로그아웃 상태 — 로그인 화면 final class SignedOut extends SessionState { - const SignedOut({this.reason = SignOutReason.none}); + const SignedOut({this.reason = SignOutReason.none, this.email}); - /// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다) + /// 로그인 화면이 위쪽 상자에 보일 까닭 final SignOutReason reason; + /// 로그인 화면의 주소 칸에 남길 주소 — 코드 단계에서 첫 단계로 돌아왔을 때(시간 지남 · 비밀번호 틀림). 비밀이 아니다 + final String? email; + @override bool operator ==(Object other) => - other is SignedOut && other.reason == reason; + other is SignedOut && other.reason == reason && other.email == email; @override - int get hashCode => reason.hashCode; + int get hashCode => Object.hash(reason, email); } /// 로그아웃한 까닭 @@ -33,6 +36,9 @@ enum SignOutReason { /// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.") codeTimedOut, + /// 코드 단계에서 보낸 비밀번호를 서버가 거절했다(`AUTH_004` — 그사이 바뀜). 위쪽 상자에 사전 문구 · 칸은 빨갛게(계약 5절) + passwordRejected, + /// [다른 계정으로 로그인] switchAccount, } diff --git a/flutter/lib/features/auth/presentation/widgets/login_fields.dart b/flutter/lib/features/auth/presentation/widgets/login_fields.dart new file mode 100644 index 0000000..c525bd1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_fields.dart @@ -0,0 +1,395 @@ +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 글자 칸 — 칸 이름은 칸 위, 안내 · 오류는 칸 아래(앱 보드 `App1Login` · 웹 PasswordField · OtpCodeField 와 같은 모양). +/// +/// - 안내와 오류는 따로 그린다 — 오류가 나도 안내는 남는다(예: 코드 칸의 "다음 코드가 뜨면 넣어 주세요"). [hintIsError] 면 안내 글이 빨갛다. +/// - 화면 낭독기: 보이는 이름은 빼고 같은 이름을 칸에 붙인다(이름을 두 번 읽지 않는다). 칸의 설명은 위쪽 상자([describedBy]) → 안내 → 오류 +/// 차례로 칸에 이어 둔다 — 칸에 다시 와도 함께 읽힌다(웹 `aria-describedby` 와 같은 결 · Android 는 칸의 hint 글). +/// 칸 아래 오류는 나타날 때도 읽어 준다(live region). 오류가 있으면 칸이 빨갛고 "잘못됨"(validationResult)이다. +/// - 비밀이 드는 칸(비밀번호 · 코드)의 글은 이 칸의 컨트롤러(화면 메모리)에만 있다 — 화면이 떠날 때 지운다. +class LoginTextField extends StatelessWidget { + const LoginTextField({ + required this.label, + required this.controller, + this.focusNode, + this.placeholder, + this.description, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.obscureText = false, + this.keyboardType, + this.enableSuggestions = false, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.suffix, + this.readOnly = false, + this.inputFormatters, + this.style, + this.contentPadding, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + + /// 칸 안의 흐린 글 + final String? placeholder; + + /// 이름과 칸 사이의 설명 (2단계 켜기의 ② — 보드) + final String? description; + + /// 칸 아래 안내 + final String? hint; + final bool hintIsError; + + /// 칸 아래 오류 — 사전 문구 + final String? error; + + /// 칸 아래 글 없이 빨간 테두리만 — 오류 글은 위쪽 상자에 있다(첫 단계의 `AUTH_004` — 보드) + final bool invalid; + + /// 칸을 설명하는 위쪽 상자의 글 — 칸에 이어 함께 읽힌다(보드의 `aria-describedby="err-login-alert"`) + final String? describedBy; + final bool obscureText; + final TextInputType? keyboardType; + + /// 자판의 추천 · 학습 — 기본은 끈다. 메일 주소 칸만 켠다: 끄면 Flutter Android 가 VISIBLE_PASSWORD 변형을 더해 + /// EMAIL_ADDRESS 와 겹쳐(FILTER 변형이 된다) 자판에서 @ 가 빠진다(엔진 TextInputPlugin `inputTypeFromTextInputType`) + final bool enableSuggestions; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + + /// 칸 끝의 단추([보기]) + final Widget? suffix; + + /// 보내는 동안 읽기만 + final bool readOnly; + final List? inputFormatters; + + /// 칸 글자 — 기본은 테마(bodyLarge 16) + final TextStyle? style; + final EdgeInsetsGeometry? contentPadding; + + /// 이름 글자 — 기본은 칸 이름(13 · 굵게) + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final InputDecorationThemeData decorations = InputDecorationTheme.of( + context, + ); + final String? hintText = hint; + final String? errorText = error; + final bool red = + invalid || errorText != null || (hintIsError && hintText != null); + final String? describe = description; + final String semanticsHint = [ + describedBy, + hintText, + errorText, + ].whereType().join('\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + ExcludeSemantics( + child: KeepAllText( + label, + style: + labelStyle ?? + theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + ), + if (describe != null) + KeepAllText( + describe, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Semantics( + label: label, + hint: semanticsHint.isEmpty ? null : semanticsHint, + validationResult: red + ? SemanticsValidationResult.invalid + : SemanticsValidationResult.none, + child: TextField( + controller: controller, + focusNode: focusNode, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + inputFormatters: inputFormatters, + style: style, + autocorrect: false, + enableSuggestions: enableSuggestions, + // 안내 · 오류는 입력 장식에 두지 않고 아래에 직접 그린다 — 보드처럼 칸의 왼쪽 끝에 맞추고 낱말 가운데서 줄을 바꾸지 않게 + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style?.copyWith(color: theme.colorScheme.outline), + enabledBorder: red ? decorations.errorBorder : null, + focusedBorder: red ? decorations.focusedErrorBorder : null, + suffixIcon: suffix, + contentPadding: contentPadding, + ), + ), + ), + if (hintText != null) + LoginFieldMessage( + text: hintText, + isError: hintIsError, + announce: false, + ), + if (errorText != null) + LoginFieldMessage(text: errorText, isError: true), + ], + ); + } +} + +/// 칸 아래 글 — 안내(회색 · 규칙에 어긋나면 빨강) 또는 오류(빨강). +/// [announce] 면 나타날 때 화면 낭독기가 읽는다(live region — 서버 거절 · 보내기 전 검사의 오류 글) +class LoginFieldMessage extends StatelessWidget { + const LoginFieldMessage({ + required this.text, + required this.isError, + bool? announce, + super.key, + }) : announce = announce ?? isError; + + final String text; + final bool isError; + final bool announce; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Widget message = KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: isError + ? theme.colorScheme.error + : theme.extension()!.textMuted, + ), + ); + if (!announce) return message; + return Semantics(container: true, liveRegion: true, child: message); + } +} + +/// 비밀번호 칸 — 끝에 [보기](48). 보드: 첫 단계 비밀번호 · 새 비밀번호 · 임시 비밀번호 · 지금 비밀번호 +class LoginPasswordField extends StatelessWidget { + const LoginPasswordField({ + required this.label, + required this.controller, + required this.shown, + required this.onToggle, + required this.toggleLabel, + this.focusNode, + this.placeholder, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.readOnly = false, + super.key, + }); + + final String label; + final TextEditingController controller; + + /// 글자를 보이는 중 + final bool shown; + final VoidCallback onToggle; + + /// [보기]의 화면 낭독기 이름 — "비밀번호 보기" + final String toggleLabel; + final FocusNode? focusNode; + final String? placeholder; + final String? hint; + final bool hintIsError; + final String? error; + final bool invalid; + final String? describedBy; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + final bool readOnly; + + @override + Widget build(BuildContext context) { + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: placeholder, + hint: hint, + hintIsError: hintIsError, + error: error, + invalid: invalid, + describedBy: describedBy, + obscureText: !shown, + // 보이는 동안에도 자판의 추천 · 학습을 끈다 + keyboardType: TextInputType.visiblePassword, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + suffix: PasswordVisibilityToggle( + shown: shown, + label: toggleLabel, + onToggle: onToggle, + ), + ); + } +} + +/// [보기] — 눌러 둔 상태(toggled)를 화면 낭독기가 읽는다(웹의 aria-pressed). 이름은 그대로 둔다 +class PasswordVisibilityToggle extends StatelessWidget { + const PasswordVisibilityToggle({ + required this.shown, + required this.label, + required this.onToggle, + super.key, + }); + + final bool shown; + final String label; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + label: label, + toggled: shown, + child: IconButton( + onPressed: onToggle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + icon: Icon( + shown ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: AppSize.iconMedium, + ), + ), + ), + ); + } +} + +/// 인증 앱 코드 칸 — 숫자 6자리 · 높이 56 · 큰 글자 사이를 벌린다(보드). 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. +/// +/// 숫자 자판 · 자동 채우기 이름 one-time-code(웹 `inputmode="numeric"` · `autocomplete="one-time-code"`). +class LoginCodeField extends StatelessWidget { + const LoginCodeField({ + required this.label, + required this.controller, + this.focusNode, + this.description, + this.hint, + this.error, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.readOnly = false, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + final String? description; + final String? hint; + final String? error; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final bool readOnly; + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final TextTheme text = Theme.of(context).textTheme; + final double fontSize = + text.headlineSmall?.fontSize ?? AppTypography.headline; + final TextStyle style = TextStyle( + fontSize: fontSize, + fontWeight: AppTypography.regular, + height: AppTypography.lineHeightBase, + letterSpacing: fontSize * AppTypography.letterSpacingCodeEm, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final double lineHeight = fontSize * AppTypography.lineHeightBase; + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: OtpTexts.placeholder, + description: description, + hint: hint, + error: error, + keyboardType: TextInputType.number, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: const [AutofillHints.oneTimeCode], + readOnly: readOnly, + inputFormatters: [OtpInputFormatter()], + style: style, + labelStyle: labelStyle, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: (AppSize.codeFieldHeight - lineHeight) / 2, + ), + ); + } +} + +/// 코드 칸의 글을 숫자 6자리로 다듬는다 — 붙여 넣은 "123 456" · "123-456" · 전각 숫자도 받는다(`normalizeOtpInput`) +class OtpInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final String digits = normalizeOtpInput(newValue.text); + if (digits == newValue.text) return newValue; + return TextEditingValue( + text: digits, + selection: TextSelection.collapsed(offset: digits.length), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_frame.dart b/flutter/lib/features/auth/presentation/widgets/login_frame.dart new file mode 100644 index 0000000..d7a7ce7 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_frame.dart @@ -0,0 +1,376 @@ +import 'package:arasystem_app/core/constants/app_assets.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 크기 — 앱 보드 `App1Login` (2026-11-05 확정). 여러 화면이 함께 쓰는 크기는 `AppSize` · `AppSpacing` 에 있다 +abstract final class LoginLayout { + /// 첫 단계 브랜드 띠의 전체 로고 폭 (높이는 그림 비율대로) + static const double logoWidth = 150; + + /// 다음 단계 짙은 띠의 ARA 마크 폭 + static const double markWidth = 48; + + /// 2단계 켜기의 차례 숫자(1 · 2 · 3) 칸 폭 + static const double stepNumberWidth = 18; + + /// 보내는 동안 단추를 흐리게 — 웹 로그인 단추와 같다 + static const double busyOpacity = 0.7; + + /// 보내는 동안 [다른 계정으로 로그인]을 흐리게 — 웹 LoginAccountRow 와 같다 + static const double busyLinkOpacity = 0.6; +} + +/// 로그인 틀의 상태 표시줄 — 위가 어두운 브랜드 띠라 밝은 아이콘. 탐색 막대는 건드리지 않는다(AppBar 와 같다) +const SystemUiOverlayStyle kLoginOverlayStyle = SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemStatusBarContrastEnforced: false, +); + +/// 로그인 틀의 칸 · 단추 모양 — 보드는 모서리 10 · 글자 칸 52 · 단추 48 이다(앱 전체 테마의 8 과 다르다) +ThemeData loginThemeOf(ThemeData base) { + final ColorScheme scheme = base.colorScheme; + final TextTheme text = base.textTheme; + final BorderRadius radius = BorderRadius.circular(AppRadius.lg); + final RoundedRectangleBorder shape = RoundedRectangleBorder( + borderRadius: radius, + ); + OutlineInputBorder border(Color color, double width) => OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: color, width: width), + ); + final TextStyle input = text.bodyLarge ?? const TextStyle(); + final double lineHeight = + (input.fontSize ?? AppTypography.bodyLarge) * + (input.height ?? AppTypography.lineHeightBase); + const Size buttonSize = Size.fromHeight(AppSize.touchMin); + + return base.copyWith( + inputDecorationTheme: InputDecorationThemeData( + filled: true, + fillColor: scheme.surface, + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: (AppSize.fieldHeight - lineHeight) / 2, + ), + hintStyle: input.copyWith(color: scheme.outline), + // 칸 아래 안내 · 오류는 칸이 직접 그린다(LoginFieldMessage) — 오류 테두리만 여기서 정한다 + border: border(scheme.outline, AppSize.borderWidth), + enabledBorder: border(scheme.outline, AppSize.borderWidth), + focusedBorder: border(scheme.primary, AppSize.focusRingWidth), + errorBorder: border(scheme.error, AppSize.focusRingWidth), + focusedErrorBorder: border(scheme.error, AppSize.focusRingWidth), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + textStyle: text.labelLarge, + // 보내는 동안(누를 수 없음)에도 같은 색을 흐리게 — 회색 단추로 바뀌지 않게 + disabledBackgroundColor: scheme.primary.withValues( + alpha: LoginLayout.busyOpacity, + ), + disabledForegroundColor: scheme.onPrimary, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + side: BorderSide(color: scheme.primary), + foregroundColor: scheme.onPrimaryContainer, + textStyle: text.labelLarge, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + shape: shape, + textStyle: text.labelLarge, + ), + ), + ); +} + +/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다 +class LoginBrandFrame extends StatelessWidget { + const LoginBrandFrame({required this.children, super.key}); + + /// 브랜드 띠 아래의 내용 — 사이 20 + final List children; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: const _StatusBarStrip(), + body: _LoginScroll( + children: [ + const _BrandBand(), + _LoginColumn(topPadding: AppSpacing.s28, children: children), + ], + ), + ); + } +} + +/// 다음 단계들의 틀 — 짙은 띠(56 — ARA 마크 · [다른 계정으로 로그인])는 위에 남고 내용만 흐른다 +class LoginStepFrame extends StatelessWidget { + const LoginStepFrame({ + required this.onOtherAccount, + required this.children, + this.otherAccountEnabled = true, + this.topPadding = AppSpacing.s28, + super.key, + }); + + /// [다른 계정으로 로그인] + final VoidCallback onOtherAccount; + + /// [다른 계정으로 로그인]을 누를 수 있는가 — 제한 단계는 보내는 동안 · 넘기는 동안 막는다(웹 LoginAccountRow 의 busy 와 같다). + /// 서버가 이미 비밀번호를 바꿨거나 2단계를 켰을 수 있는데 그 결과를 버리면, 직원이 옛 비밀번호로 다시 들어가다 막힌다 + final bool otherAccountEnabled; + + /// 띠 아래의 내용 — 사이 20 + final List children; + + /// 띠와 첫 내용 사이 (보드: 코드 · 새 비밀번호 28, 2단계 켜기 24) + final double topPadding; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: _StepBand( + onOtherAccount: otherAccountEnabled ? onOtherAccount : null, + ), + body: _LoginScroll( + children: [ + _LoginColumn(topPadding: topPadding, children: children), + ], + ), + ); + } +} + +class _LoginScaffold extends StatelessWidget { + const _LoginScaffold({required this.top, required this.body}); + + final Widget top; + final Widget body; + + @override + Widget build(BuildContext context) { + final ThemeData theme = loginThemeOf(Theme.of(context)); + return AnnotatedRegion( + value: kLoginOverlayStyle, + child: Theme( + data: theme, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + top, + Expanded(child: body), + ], + ), + ), + ), + ); + } +} + +/// 상태 표시줄 뒤 — 브랜드 바탕(내용이 흘러 올라가도 밝은 아이콘이 보이게) +class _StatusBarStrip extends StatelessWidget { + const _StatusBarStrip(); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Theme.of(context).colorScheme.secondary, + child: SizedBox(height: MediaQuery.paddingOf(context).top), + ); + } +} + +class _LoginScroll extends StatelessWidget { + const _LoginScroll({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} + +/// 내용 열 — 휴대폰은 화면 폭을 다 쓰고, 넓은 화면은 [AppSize.formMaxWidth] 로 가운데 +class _LoginColumn extends StatelessWidget { + const _LoginColumn({required this.topPadding, required this.children}); + + final double topPadding; + final List children; + + @override + Widget build(BuildContext context) { + return _FormWidth( + child: Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.s20, + topPadding, + AppSpacing.s20, + AppSpacing.s32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s20, + children: children, + ), + ), + ); + } +} + +class _FormWidth extends StatelessWidget { + const _FormWidth({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppSize.formMaxWidth), + child: child, + ), + ); + } +} + +/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글 +class _BrandBand extends StatelessWidget { + const _BrandBand(); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: _FormWidth( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s36, + AppSpacing.s24, + AppSpacing.s28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s18, + children: [ + Image.asset( + AppAssets.logoFull, + width: LoginLayout.logoWidth, + semanticLabel: kAppName, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + LoginTexts.brandHeadline, + // 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand) + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + KeepAllText( + LoginTexts.brandLead, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.onBrandMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// 다음 단계의 짙은 띠 — ARA 마크와 [다른 계정으로 로그인] (웹처럼 주소 줄 안에 두면 390 폭에 들지 않는다) +class _StepBand extends StatelessWidget { + const _StepBand({required this.onOtherAccount}); + + /// null 이면 누를 수 없다(흐리게) + final VoidCallback? onOtherAccount; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: SafeArea( + top: false, + bottom: false, + child: SizedBox( + height: AppSize.topBandHeight, + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s20, + right: AppSpacing.s6, + ), + child: Row( + children: [ + Image.asset( + AppAssets.logoMark, + width: LoginLayout.markWidth, + semanticLabel: kAppName, + ), + const Spacer(), + TextButton( + onPressed: onOtherAccount, + style: TextButton.styleFrom( + foregroundColor: extra.onBrandLink, + disabledForegroundColor: extra.onBrandLink.withValues( + alpha: LoginLayout.busyLinkOpacity, + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(LoginBandTexts.otherAccount), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_parts.dart b/flutter/lib/features/auth/presentation/widgets/login_parts.dart new file mode 100644 index 0000000..a5c9d26 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_parts.dart @@ -0,0 +1,317 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 단계의 머리 — 제목 · 설명 (다음 단계는 로그인한 주소 줄도). +/// +/// 제목은 화면 낭독기의 머리글이자 **화면 이름**이다(`namesRoute`) — 단계가 바뀌어 화면이 바뀌면 낭독기가 이 글을 읽어 알린다. +class LoginHeading extends StatelessWidget { + const LoginHeading({ + required this.title, + required this.subtitle, + this.email, + super.key, + }); + + final String title; + final String subtitle; + + /// 로그인한(비밀번호를 확인한) 주소 — 다음 단계만 + final String? email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final String? address = email; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: address == null ? AppSpacing.s4 : AppSpacing.s10, + children: [ + Semantics( + header: true, + namesRoute: true, + child: KeepAllText(title, style: theme.textTheme.headlineSmall), + ), + KeepAllText( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (address != null) LoginAccountChip(email: address), + ], + ); + } +} + +/// 로그인한 주소 — 사람 아이콘 + 주소(넘치면 줄임)의 알약(36) +class LoginAccountChip extends StatelessWidget { + const LoginAccountChip({required this.email, super.key}); + + final String email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + constraints: const BoxConstraints(minHeight: AppSize.chipHeight), + padding: const EdgeInsetsDirectional.only( + start: AppSpacing.s10, + end: AppSpacing.s14, + ), + decoration: ShapeDecoration( + color: theme.colorScheme.surfaceContainerHigh, + shape: const StadiumBorder(), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.person_outline, + size: AppSize.iconSmall, + color: extra.textMuted, + ), + Flexible( + child: Text( + email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 위쪽 빨간 상자 — 사전 문구(`AUTH_004` 등)나 단계 안내. 나타나면 화면 낭독기가 읽는다(live region — 웹의 role="alert"). +/// [actionLabel] 이 있으면 글 아래에 단추([다시 시도]) — [onAction] 이 null 이면(다시 하는 중) 자리는 두고 누를 수 없다. +class LoginAlertBox extends StatelessWidget { + const LoginAlertBox({ + required this.message, + this.actionLabel, + this.onAction, + this.namesRoute = false, + super.key, + }); + + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + /// 이 상자가 화면 이름이다(제목 없는 화면 — 모르는 제한) + final bool namesRoute; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String? label = actionLabel; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: AppSpacing.s12, + ), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + Semantics( + container: true, + liveRegion: true, + namesRoute: namesRoute, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.error_outline, + size: AppSize.iconSmall, + color: scheme.error, + ), + Expanded( + child: KeepAllText( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + if (label != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: OutlinedButton( + onPressed: onAction, + style: OutlinedButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + side: BorderSide(color: scheme.outlineVariant), + backgroundColor: scheme.surface, + foregroundColor: theme + .extension()! + .textStrong, + ), + child: Text(label), + ), + ), + ], + ), + ); + } +} + +/// 단계의 큰 단추 — 보내는 동안은 [busyLabel] 로 바꾸고 누를 수 없다(두 번 보내면 서버가 `REQ_001` — 계약 1절) +class LoginSubmitButton extends StatelessWidget { + const LoginSubmitButton({ + required this.label, + required this.busyLabel, + required this.busy, + required this.onPressed, + this.enabled = true, + this.inForm = false, + super.key, + }); + + final String label; + final String busyLabel; + final bool busy; + final VoidCallback onPressed; + + /// 보내는 중이 아니어도 누를 수 없음(키를 받는 중 · 이미 마쳤는데 넘기지 못함) + final bool enabled; + + /// 칸 묶음(사이 16) 안의 단추 — 위를 4 더 띄워 칸과 20 을 둔다(보드) + final bool inForm; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: inForm ? AppSpacing.s4 : 0), + child: FilledButton( + onPressed: busy || !enabled ? null : onPressed, + child: Text(busy ? busyLabel : label), + ), + ); + } +} + +/// 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처(진하게), 없거나 받지 못했으면 지금 글(웹과 같다). 글자로만 보인다 +class LoginSupportLine extends StatelessWidget { + const LoginSupportLine({ + required this.question, + required this.fallback, + required this.contact, + super.key, + }); + + final String question; + final String fallback; + + /// 연락처 한 줄 — 없으면 null + final String? contact; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ); + final String? line = contact; + if (line == null) return KeepAllText(fallback, style: style); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: '${keepAllWordBreak(question)}\n'), + TextSpan( + text: keepAllWordBreak(line), + style: TextStyle( + color: extra.textStrong, + fontWeight: AppTypography.medium, + ), + ), + ], + ), + style: style, + semanticsLabel: '$question\n$line', + ); + } +} + +/// 맨 아래 안내 — 위쪽 옅은 선 · 작은 회색 글(메일 프로그램 연결 안내) +class LoginNote extends StatelessWidget { + const LoginNote({required this.text, super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: extra.borderSubtle)), + ), + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ), + ); + } +} + +/// 2단계 켜기의 차례 하나 — 청록 굵은 숫자(동그라미 없음) + 내용 (숫자는 화면 낭독기가 읽지 않는다 — 보드의 aria-hidden) +class LoginStepItem extends StatelessWidget { + const LoginStepItem({required this.number, required this.child, super.key}); + + final int number; + final Widget child; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s12, + children: [ + SizedBox( + width: LoginLayout.stepNumberWidth, + child: ExcludeSemantics( + child: Text( + '$number', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/flutter/lib/shared/widgets/keep_all_text.dart b/flutter/lib/shared/widgets/keep_all_text.dart new file mode 100644 index 0000000..e27ff8c --- /dev/null +++ b/flutter/lib/shared/widgets/keep_all_text.dart @@ -0,0 +1,17 @@ +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:flutter/widgets.dart'; + +/// 여러 줄 한국어 글 — 낱말 가운데서 줄이 바뀌지 않는다(웹 `word-break: keep-all` — `keepAllWordBreak`). +/// +/// 화면 낭독기에는 원래 글을 준다(보이지 않는 줄 나눔 표시가 읽히지 않게). +class KeepAllText extends StatelessWidget { + const KeepAllText(this.text, {this.style, super.key}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text(keepAllWordBreak(text), style: style, semanticsLabel: text); + } +} diff --git a/flutter/linux/flutter/generated_plugin_registrant.cc b/flutter/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/flutter/linux/flutter/generated_plugin_registrant.cc +++ b/flutter/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/flutter/linux/flutter/generated_plugins.cmake b/flutter/linux/flutter/generated_plugins.cmake index ce58916..7e7bd77 100644 --- a/flutter/linux/flutter/generated_plugins.cmake +++ b/flutter/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 348142a..820b636 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import device_info_plus import flutter_secure_storage_darwin import package_info_plus import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index b0c0ca1..861a6cc 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -762,6 +762,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" vector_math: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 2eb9065..42ed8cb 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: package_info_plus: ^10.2.2 # 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart) shared_preferences: ^2.5.6 + # 다른 앱 열기 — 로그인 2단계 켜기의 [인증 앱에 추가](otpauth 주소 — features/auth/data/authenticator_launcher.dart). + # 6.3.3 은 2026-10-02 배포(Flutter 팀). canLaunchUrl 은 쓰지 않는다 — debug 판 안드로이드가 주소째 로그에 남긴다 + url_launcher: ^6.3.3 dev_dependencies: flutter_test: @@ -49,6 +52,8 @@ flutter: - .env # 글꼴 라이선스 — 설정의 [오픈소스 라이선스]에 보인다 (core/theme/app_fonts.dart) - assets/licenses/pretendard-ofl.txt + # 흰 로고 둘 — 웹 frontend/src/assets/brand/ 와 같은 파일 (core/constants/app_assets.dart) + - assets/brand/ # Pretendard 1.3.9 (SIL OFL 1.1) — 웹(frontend)과 같은 글꼴. 정적 OTF 넷만 넣는다 fonts: diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index b2e8263..4a84e83 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -5,9 +5,11 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/error_lexicon.dart'; import 'package:arasystem_app/core/api/error_notifier.dart'; import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/router/app_router.dart'; import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; @@ -17,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'support/fakes.dart'; +import 'support/login_fakes.dart'; import 'support/provider_harness.dart'; void main() { @@ -43,13 +46,12 @@ void main() { await pumpApp(tester); - expect(find.text(ScreenTitles.login), findsOneWidget); + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(LoginTexts.subtitle), findsOneWidget); final MaterialApp app = tester.widget(find.byType(MaterialApp)); expect(app.title, kAppName); expect(app.locale, kAppLocale); - final BuildContext context = tester.element( - find.text(ScreenTitles.login), - ); + final BuildContext context = tester.element(find.byType(LoginPage)); expect(Localizations.localeOf(context).languageCode, 'ko'); }); @@ -72,7 +74,7 @@ void main() { await pumpApp(tester); - expect(find.text(ScreenTitles.passwordChange), findsOneWidget); + expect(findCopy(ScreenTitles.passwordChange), findsOneWidget); }); testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async { @@ -227,7 +229,7 @@ void main() { await tester.tap(page(SettingsTexts.signOut)); await tester.pumpAndSettle(); - expect(find.text(ScreenTitles.login), findsOneWidget); + expect(find.byType(LoginPage), findsOneWidget); expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); }); diff --git a/flutter/test/core/api/auth_interceptor_test.dart b/flutter/test/core/api/auth_interceptor_test.dart index 2b46a69..9bb61a6 100644 --- a/flutter/test/core/api/auth_interceptor_test.dart +++ b/flutter/test/core/api/auth_interceptor_test.dart @@ -115,25 +115,29 @@ void main() { expect(refreshCount(), 2); }); - test('미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 한 번 알린다', () async { - await api.coldStartWithRefreshToken('refresh-1'); - api.server.on( - 'POST', - ApiPaths.appRefresh, - (_) => FakeResponse.fail(401, 'AUTH_001'), - ); - protectedRoute(validToken: 'access-2'); + test( + '미리 갱신이 거절되면 원래 요청을 보내지 않고 AUTH_002 로 끝낸다 — 알림은 로그인 화면의 위쪽 상자가 한다', + () async { + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + protectedRoute(validToken: 'access-2'); - final ApiException error = apiErrorOf( - await errorOf(api.dio.get(_mailState)), - ); + final ApiException error = apiErrorOf( + await errorOf(api.dio.get(_mailState)), + ); - expect(error.code, 'AUTH_002'); - expect(api.server.received('GET', _mailState), isEmpty); - expect(api.notifier.shownCodes, ['AUTH_002']); - expect(api.emitted.whereType(), hasLength(1)); - expect(api.store.hasSession, isFalse); - }); + expect(error.code, 'AUTH_002'); + expect(api.server.received('GET', _mailState), isEmpty); + // 세션 끝은 늘 로그인 화면으로 가고 그 화면의 위쪽 상자가 같은 사전 문구로 알린다 — 스낵바는 띄우지 않는다 + expect(api.notifier.shownCodes, isEmpty); + expect(api.emitted.whereType(), hasLength(1)); + expect(api.store.hasSession, isFalse); + }, + ); test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async { await api.coldStartWithRefreshToken('refresh-1'); @@ -262,7 +266,7 @@ void main() { expect(second.files.single.value.filename, 'a.txt'); }); - test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async { + test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 끝내며 세션 끝을 알린다(스낵바 없음)', () async { await api.signIn(); api.server.on( 'POST', @@ -280,12 +284,12 @@ void main() { expect(api.store.hasSession, isFalse); expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse); expect(api.emitted.whereType(), hasLength(1)); - expect(api.notifier.shownCodes, ['AUTH_002']); + expect(api.notifier.shownCodes, isEmpty); // 원래 요청은 다시 보내지 않았다 expect(api.server.received('GET', _mailState), hasLength(1)); }); - test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async { + test('동시에 세션이 끝난 요청들은 갱신 한 번으로 모두 AUTH_002 — 스낵바는 띄우지 않는다', () async { await api.signIn(); api.server.on( 'POST', @@ -304,7 +308,7 @@ void main() { 'AUTH_002', ]); expect(refreshCount(), 1); - expect(api.notifier.shownCodes, ['AUTH_002']); + expect(api.notifier.shownCodes, isEmpty); }); test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async { diff --git a/flutter/test/core/utils/login_input_test.dart b/flutter/test/core/utils/login_input_test.dart new file mode 100644 index 0000000..09d1bda --- /dev/null +++ b/flutter/test/core/utils/login_input_test.dart @@ -0,0 +1,136 @@ +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('인증 앱 코드 칸 다듬기', () { + test('숫자만 남기고 6자리에서 자른다 — 붙여 넣은 띄어쓰기 · 하이픈은 지운다', () { + expect(normalizeOtpInput('123 456'), '123456'); + expect(normalizeOtpInput('123-456'), '123456'); + expect(normalizeOtpInput(' 12a3b4 56 789'), '123456'); + expect(normalizeOtpInput(''), ''); + }); + + test('전각 숫자도 반각으로 받는다(웹의 NFKC 와 같다)', () { + expect(normalizeOtpInput('123 456'), '123456'); + }); + + test('숫자 6자리일 때만 다 넣은 것이다', () { + expect(isCompleteOtp('123456'), isTrue); + expect(isCompleteOtp('12345'), isFalse); + expect(isCompleteOtp('1234567'), isFalse); + expect(isCompleteOtp('12345a'), isFalse); + expect(isCompleteOtp(''), isFalse); + }); + }); + + group('새 비밀번호 규칙 (웹 password-change.ts 와 같다)', () { + test('8자 미만이거나 지금 비밀번호와 같으면 안내 글이 오류가 된다', () { + expect( + passwordChangeProblems( + current: 'temp-1234', + next: 'short', + confirm: 'short', + ).next, + NewPasswordProblem.rule, + ); + expect( + passwordChangeProblems( + current: 'temp-1234', + next: 'temp-1234', + confirm: 'temp-1234', + ).next, + NewPasswordProblem.rule, + ); + }); + + test('128자를 넘으면 tooLong, 확인 칸이 다르면 mismatch, 지금 비밀번호가 비면 missing', () { + final String long = 'a' * (kPasswordMaxLength + 1); + final PasswordChangeProblems problems = passwordChangeProblems( + current: '', + next: long, + confirm: 'other', + ); + expect(problems.next, NewPasswordProblem.tooLong); + expect(problems.confirmMismatch, isTrue); + expect(problems.currentMissing, isTrue); + expect(problems.hasAny, isTrue); + }); + + test('규칙에 맞으면 문제가 없다 — 공백도 비밀번호의 글자다', () { + final PasswordChangeProblems problems = passwordChangeProblems( + current: 'temp-1234', + next: ' new pass ', + confirm: ' new pass ', + ); + expect(problems.hasAny, isFalse); + }); + }); + + group('문의 연락처 한 줄 (웹 support-contact.ts 와 같다)', () { + test('줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지운다', () { + expect( + supportContactTextOf(' IT 담당\n홍길동 02-000-0000 '), + 'IT 담당 홍길동 02-000-0000', + ); + }); + + test('빈 글 · 글이 아닌 값은 null(정하지 않음), 200자에서 자른다', () { + expect(supportContactTextOf(null), isNull); + expect(supportContactTextOf(' '), isNull); + expect(supportContactTextOf(3), isNull); + expect( + supportContactTextOf('가' * 250), + hasLength(kSupportContactMaxLength), + ); + }); + }); + + group('2단계 인증 키 줄', () { + test('4자씩 끊어 한 줄에 4묶음 — 32자 키는 두 줄이다', () { + expect(keyLines('K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'), [ + 'K7QF 2M4X HB6R TZ3N', + 'PW5C YL7D JQ2V MGAS', + ]); + }); + + test('길이가 4의 배수가 아니어도 남은 글자를 잃지 않는다', () { + expect(keyLines('ABCDEFGHIJ'), ['ABCD EFGH IJ']); + expect(keyLines(''), isEmpty); + }); + }); + + group('한국어 줄 나눔 (keep-all)', () { + // WORD JOINER(U+2060) — 보이지 않는 글자라 코드 포인트로 만든다 + final String joiner = String.fromCharCode(0x2060); + + test('한글 낱말 안에 줄 나눔 막기를 넣고 띄어쓰기는 그대로 둔다', () { + final String first = ['자', '동', '으', '로'].join(joiner); + final String second = ['맞', '춰'].join(joiner); + expect(keepAllWordBreak('자동으로 맞춰'), '$first $second'); + }); + + test('한글과 붙은 영문 · 기호도 한 낱말로 남는다', () { + expect( + keepAllWordBreak('Outlook이나'), + ['Outlook', '이', '나'].join(joiner), + ); + expect( + keepAllWordBreak('[설정]에서'), + ['[', '설', '정', ']', '에', '서'].join(joiner), + ); + }); + + test('줄 나눔 막기를 지우면 원래 글이다', () { + const String text = 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은'; + expect(keepAllWordBreak(text).replaceAll(joiner, ''), text); + }); + + test('한글이 없는 낱말 · 이모지 옆에는 넣지 않는다(글자 묶음을 가르지 않게)', () { + expect(keepAllWordBreak('02-000-0000 @it'), '02-000-0000 @it'); + expect(keepAllWordBreak('가👍나'), '가👍나'); + }); + }); +} diff --git a/flutter/test/error_lexicon_test.dart b/flutter/test/error_lexicon_test.dart index f455b57..c391edd 100644 --- a/flutter/test/error_lexicon_test.dart +++ b/flutter/test/error_lexicon_test.dart @@ -20,7 +20,9 @@ void main() { test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () { // 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) · - // 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다. + // 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes) · + // 제한 단계(새 비밀번호 · 2단계 켜기 — kPasswordChangeInlineCodes · kTwoFactorSetupInlineCodes 의 AUTH_005 · AUTH_008)가 + // 이 코드들을 직접 사용한다. // // ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서 // 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다. @@ -32,8 +34,10 @@ void main() { 'AUTH_002', 'AUTH_003', 'AUTH_004', + 'AUTH_005', 'AUTH_006', 'AUTH_007', + 'AUTH_008', 'AUTH_010', 'VAL_001', 'RES_001', diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart new file mode 100644 index 0000000..ea5118e --- /dev/null +++ b/flutter/test/features/auth/login_pages_test.dart @@ -0,0 +1,1873 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_client.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/restricted_login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_page.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; + +/// 로그인 틀 네 단계 (앱 보드 `App1Login`) — 앱 전체를 가짜 서버로 띄워 단계 · 오류 · 비밀 지우기 · 늦은 결과를 본다 +void main() { + late LoginHarness h; + + setUp(() => h = LoginHarness()); + + Finder button(String label) => find.widgetWithText(FilledButton, label); + + /// 단계의 제목 — 단추에도 같은 글("로그인")이 있어 머리 안에서 찾는다 + Finder heading(String title) => + find.descendant(of: find.byType(LoginHeading), matching: findCopy(title)); + + Finder otherAccount() => + find.widgetWithText(TextButton, LoginBandTexts.otherAccount); + + /// [다른 계정으로 로그인]을 지금 누를 수 있나 + bool otherAccountEnabled(WidgetTester tester) => + tester.widget(otherAccount()).onPressed != null; + + /// 위쪽 상자 안의 글 + Finder alertWith(String text) => + find.descendant(of: find.byType(LoginAlertBox), matching: findCopy(text)); + + /// 칸 이름으로 그 칸의 화면 낭독기 노드(칸에 붙인 이름 · 설명이 합쳐진 곳)를 찾는다 + Finder fieldEditable(String label) => find.descendant( + of: fieldInput(label), + matching: find.byType(EditableText), + ); + + String message(String code) => ErrorLexicon.getMessage(code); + + Map bodyOf(RequestOptions options) => + options.data! as Map; + + List logins() => h.server.received('POST', ApiPaths.appLogin); + + /// 로그인 응답 — 2단계를 켠 직원이면 코드 단계를 거친다 + void loginAnswers({String? restriction, bool twoFactor = false}) { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + if (twoFactor && bodyOf(options)['otpCode'] == null) { + return codeStepResponse(); + } + return signedInResponse(1, now: h.clock.now, restriction: restriction); + }); + } + + Future tapAndSettle(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + Future submitLogin( + WidgetTester tester, { + String email = kTestEmail, + String password = 'temp-pw-1', + }) async { + await tester.enterText(fieldInput(LoginTexts.emailLabel), email); + await tester.enterText(fieldInput(LoginTexts.passwordLabel), password); + await tapAndSettle(tester, button(LoginTexts.submit)); + } + + Future submitCode(WidgetTester tester, String code) async { + await tester.enterText(fieldInput(LoginCodeTexts.codeLabel), code); + await tapAndSettle(tester, button(LoginCodeTexts.submit)); + } + + group('첫 단계 — 주소와 비밀번호', () { + testWidgets('보드의 글 — 브랜드 띠 · 제목 · 칸 이름 · 문의 줄의 연락처', ( + WidgetTester tester, + ) async { + h.supportContact('IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'); + + await h.pump(tester); + + expect(find.byType(LoginPage), findsOneWidget); + expect(heading(ScreenTitles.login), findsOneWidget); + for (final String text in [ + LoginTexts.brandHeadline, + LoginTexts.brandLead, + LoginTexts.subtitle, + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(find.text(LoginTexts.emailPlaceholder), findsOneWidget); + expect(find.text(LoginTexts.passwordPlaceholder), findsOneWidget); + expect(button(LoginTexts.submit), findsOneWidget); + expect( + findCopy( + '${SupportLineTexts.passwordQuestion}\nIT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it', + ), + findsOneWidget, + ); + // 로그인 없이 묻는다 + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single), + isNull, + ); + // 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다 + expect(find.textContaining('IMAP'), findsNothing); + }); + + testWidgets('연락처를 받지 못하면 지금 글 그대로 — 알리지 않는다', (WidgetTester tester) async { + await h.pump(tester); + + expect(findCopy(SupportLineTexts.passwordFallback), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('[보기]는 비밀번호를 보이고 눌린 상태를 화면 낭독기에 알린다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + TextField password() => + tester.widget(fieldInput(LoginTexts.passwordLabel)); + expect(password().obscureText, isTrue); + expect( + tester.getSemantics(find.byType(PasswordVisibilityToggle)), + isSemantics( + label: LoginTexts.showPassword, + isButton: true, + hasToggledState: true, + isToggled: false, + ), + ); + + await tapAndSettle(tester, find.byType(PasswordVisibilityToggle)); + + expect(password().obscureText, isFalse); + expect( + tester.getSemantics(find.byType(PasswordVisibilityToggle)), + isSemantics(hasToggledState: true, isToggled: true), + ); + semantics.dispose(); + }); + + testWidgets('빈 칸은 보내지 않고 그 칸 아래에 VAL_001 사전 문구', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + await tapAndSettle(tester, button(LoginTexts.submit)); + + expect(logins(), isEmpty); + // 두 칸 모두 비었다 — 칸마다 아래에 + expect(findCopy(message('VAL_001')), findsNWidgets(2)); + expect( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && w.label == LoginTexts.emailLabel, + ), + matching: findCopy(message('VAL_001')), + ), + findsOneWidget, + ); + }); + + testWidgets('AUTH_004 — 위쪽 상자에 사전 문구, 두 칸 빨강, 비밀번호는 비우고 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + await h.pump(tester); + + await submitLogin(tester, password: 'wrong'); + + expect(findCopy(message('AUTH_004')), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + expect(fieldText(tester, LoginTexts.passwordLabel), isEmpty); + for (final String label in [ + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + final TextField field = tester.widget(fieldInput(label)); + expect( + field.decoration?.enabledBorder?.borderSide.color, + Theme.of(tester.element(fieldInput(label))).colorScheme.error, + reason: label, + ); + } + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('그 밖의 실패(REQ_001)는 전역 알림 하나 — 칸 · 상자에 보이지 않는다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(429, 'REQ_001'), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(h.notifier.shownCodes, ['REQ_001']); + expect(findCopy(message('REQ_001')), findsNothing); + }); + + testWidgets('앱이 정한 실패(응답 모양이 틀림 — SYS_001)도 전역 알림으로 보인다', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({'status': 'signedIn'}), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(h.notifier.shownCodes, ['SYS_001']); + expect(find.byType(LoginPage), findsOneWidget); + }); + + testWidgets('보내는 동안 단추는 「로그인하는 중…」이고 두 번 보내지 않는다', ( + WidgetTester tester, + ) async { + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (_) async { + await release.future; + return FakeResponse.fail(401, 'AUTH_004'); + }); + await h.pump(tester); + await tester.enterText(fieldInput(LoginTexts.emailLabel), kTestEmail); + await tester.enterText(fieldInput(LoginTexts.passwordLabel), 'pw'); + + await tester.tap(button(LoginTexts.submit)); + await tester.pump(); + expect(button(LoginTexts.submitting), findsOneWidget); + expect( + tester.widget(button(LoginTexts.submitting)).onPressed, + isNull, + ); + await tester.tap(button(LoginTexts.submitting), warnIfMissed: false); + await tester.pump(); + + release.complete(); + await tester.pumpAndSettle(); + expect(logins(), hasLength(1)); + expect(button(LoginTexts.submit), findsOneWidget); + }); + + testWidgets('주소 칸은 자판 추천을 켠다(@ 가 있는 주소 자판) — 고쳐 쓰기는 끄고, 비밀번호 칸은 둘 다 끈다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + final TextField email = tester.widget( + fieldInput(LoginTexts.emailLabel), + ); + expect(email.keyboardType, TextInputType.emailAddress); + expect(email.enableSuggestions, isTrue); + expect(email.autocorrect, isFalse); + final TextField password = tester.widget( + fieldInput(LoginTexts.passwordLabel), + ); + expect(password.enableSuggestions, isFalse); + expect(password.autocorrect, isFalse); + }); + + testWidgets('위쪽 상자의 글(AUTH_004)은 두 칸의 설명으로도 붙는다 — 칸에 다시 와도 함께 읽힌다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.fail(401, 'AUTH_004'), + ); + await h.pump(tester); + + await submitLogin(tester, password: 'wrong'); + + for (final String label in [ + LoginTexts.emailLabel, + LoginTexts.passwordLabel, + ]) { + expect( + tester.getSemantics(fieldEditable(label)).hint, + contains(message('AUTH_004')), + reason: label, + ); + } + semantics.dispose(); + }); + + testWidgets('세션이 끝나 돌아오면 위쪽 상자에 AUTH_002 — 전역 알림은 띄우지 않는다', ( + WidgetTester tester, + ) async { + loginAnswers(); + final ProviderContainer container = await h.pump(tester); + await submitLogin(tester); + expect(find.byType(NavigationBar), findsOneWidget); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + Object? failure; + unawaited( + container + .read(dioProvider) + .get('/mailboxes') + .then((_) {}, onError: (Object error) => failure = error), + ); + await tester.pumpAndSettle(); + + expect(apiErrorOf(failure!).code, 'AUTH_002'); + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(SnackBar), findsNothing); + }); + }); + + group('둘째 단계 — 2단계 인증 코드', () { + testWidgets('비밀번호가 맞고 2단계를 켰으면 코드 단계 — 짙은 띠 · 주소 · 칸 · 안내', ( + WidgetTester tester, + ) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(LoginCodePage), findsOneWidget); + for (final String text in [ + ScreenTitles.loginCode, + LoginCodeTexts.subtitle, + LoginCodeTexts.codeLabel, + SupportLineTexts.authenticatorFallback, + LoginCodeTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(find.text(kTestEmail), findsOneWidget); + expect(otherAccount(), findsOneWidget); + // 코드 칸에 초점 — 숫자 자판 · one-time-code + final TextField code = tester.widget( + fieldInput(LoginCodeTexts.codeLabel), + ); + expect(code.focusNode?.hasFocus, isTrue); + expect(code.keyboardType, TextInputType.number); + expect(code.autofillHints, contains(AutofillHints.oneTimeCode)); + }); + + testWidgets('붙여 넣은 글은 숫자 6자리로 다듬는다', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(LoginCodeTexts.codeLabel), + '123 456 78', + ); + await tester.pump(); + + expect(fieldText(tester, LoginCodeTexts.codeLabel), '123456'); + }); + + testWidgets('6자리가 아니면 보내지 않고 칸 아래에 AUTH_007', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '12345'); + + expect(logins(), hasLength(1)); + expect(findCopy(message('AUTH_007')), findsOneWidget); + }); + + testWidgets('AUTH_006 — 칸 아래 사전 문구 · 칸을 비우고 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(400, 'AUTH_006'); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '111111'); + + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(fieldText(tester, LoginCodeTexts.codeLabel), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(LoginCodePage), findsOneWidget); + // 비밀번호를 한 번 더 실어 보냈다(서버는 비밀번호를 들고 있지 않다) + expect(bodyOf(logins().last), { + 'email': kTestEmail, + 'password': 'temp-pw-1', + 'otpCode': '111111', + }); + }); + + testWidgets('AUTH_004(그사이 비밀번호가 바뀜) — 첫 단계로 돌아가 주소를 남기고 위쪽 상자', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(401, 'AUTH_004'); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(message('AUTH_004')), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + expect(fieldText(tester, LoginTexts.passwordLabel), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('10분 넘게 머물면 [확인]에 보내지 않고 첫 단계로 — 주소를 남기고 시간 지남 안내', ( + WidgetTester tester, + ) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + h.clock.advance(const Duration(minutes: 10)); + await tester.pump(const Duration(minutes: 10)); + await submitCode(tester, '123456'); + + expect(logins(), hasLength(1)); + expect(find.byType(LoginPage), findsOneWidget); + expect(findCopy(LoginTexts.codeTimedOut), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), kTestEmail); + }); + + testWidgets('[다른 계정으로 로그인]은 보내는 중에도 첫 단계로 — 늦은 결과는 보이지 않고 그 세션을 끝낸다', ( + WidgetTester tester, + ) async { + final Completer release = Completer(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async { + if (bodyOf(options)['otpCode'] == null) return codeStepResponse(); + await release.future; + return signedInResponse(1, now: h.clock.now); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText(fieldInput(LoginCodeTexts.codeLabel), '123456'); + await tester.tap(button(LoginCodeTexts.submit)); + await tester.pump(); + + await tapAndSettle(tester, otherAccount()); + release.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(LoginPage), findsOneWidget); + expect(fieldText(tester, LoginTexts.emailLabel), isEmpty); + expect(find.byType(NavigationBar), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + }); + + testWidgets( + 'REQ_001(시도 과다) — 위쪽 상자에 사전 문구, 코드는 그대로 · 알리지 않고 다음에 보낼 때 내린다', + (WidgetTester tester) async { + Completer? release; + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async { + if (bodyOf(options)['otpCode'] == null) return codeStepResponse(); + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return signedInResponse(1, now: h.clock.now); + }); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, LoginCodeTexts.codeLabel), '123456'); + expect(find.byType(LoginCodePage), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + // 같은 코드로 다시 보낸다 — 보내는 순간 상자를 내린다 + release = Completer(); + await tester.tap(button(LoginCodeTexts.submit)); + await tester.pump(Duration.zero); + // 보내는 중(서버가 아직 답하지 않았다) — 상자는 이미 내렸다 + expect(logins(), hasLength(3)); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }, + ); + + testWidgets('코드가 맞고 제한이 없으면 메일 화면으로 간다', (WidgetTester tester) async { + loginAnswers(twoFactor: true); + await h.pump(tester); + await submitLogin(tester); + + await submitCode(tester, '123456'); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(LoginCodePage), findsNothing); + }); + }); + + group('셋째 단계 — 새 비밀번호 정하기', () { + testWidgets('보드의 글 — 로그인 때 넣은 임시 비밀번호를 쥐고 있으면 그 칸이 없다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(PasswordChangePage), findsOneWidget); + for (final String text in [ + ScreenTitles.passwordChange, + PasswordChangeTexts.subtitle, + PasswordChangeTexts.nextLabel, + PasswordChangeTexts.nextHint, + PasswordChangeTexts.confirmLabel, + PasswordChangeTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect(button(PasswordChangeTexts.submit), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsNothing); + // 코드 단계를 거치지 않았으니 2단계 상태를 묻는다 — 꺼져 있으면(여기선 404) 코드 칸은 없다 + expect(h.server.received('GET', ApiPaths.twoFactor), hasLength(1)); + expect(findCopy(PasswordChangeTexts.codeLabel), findsNothing); + }); + + testWidgets('규칙에 맞지 않으면 보내지 않는다 — 안내 글이 빨강, 확인이 다르면 그 칸 아래', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'temp-pw-1', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'other', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + final Text hint = tester.widget( + findCopy(PasswordChangeTexts.nextHint), + ); + expect( + hint.style?.color, + Theme.of( + tester.element(findCopy(PasswordChangeTexts.nextHint)), + ).colorScheme.error, + ); + expect(findCopy(PasswordChangeTexts.mismatch), findsOneWidget); + }); + + testWidgets('바꾸면 새 토큰을 받아 2단계 켜기로 — 알림, 지금 비밀번호는 새 비밀번호다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + { + 'currentPassword': 'temp-pw-1', + 'newPassword': 'new-pw-1234', + }, + ); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + expect(find.text(PasswordChangeTexts.done), findsOneWidget); + + // 2단계 켜기는 방금 정한 새 비밀번호를 지금 비밀번호로 쓴다(③ 칸 없음) + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsNothing); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).single), + containsPair('currentPassword', 'new-pw-1234'), + ); + }); + + testWidgets('쥔 임시 비밀번호를 서버가 거절하면(AUTH_005) 임시 비밀번호 칸이 그 오류와 함께 보인다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + await h.pump(tester); + await submitLogin(tester); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + expect( + find.descendant( + of: find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && + w.label == PasswordChangeTexts.temporaryLabel, + ), + matching: findCopy(message('AUTH_005')), + ), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + + // 칸에 넣은 임시 비밀번호로 다시 보낸다 + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.temporaryLabel), + 'typed-temp', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).last), + containsPair('currentPassword', 'typed-temp'), + ); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('코드 단계를 거쳐 왔으면 코드 칸 — 다음 코드를 함께 보낸다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange', twoFactor: true); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + await submitLogin(tester); + await submitCode(tester, '123456'); + + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect(findCopy(PasswordChangeTexts.codeHint), findsOneWidget); + // 코드 단계를 거쳤으니 묻지 않는다 + expect(h.server.received('GET', ApiPaths.twoFactor), isEmpty); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect(findCopy(message('AUTH_007')), findsOneWidget); + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + containsPair('otpCode', '654321'), + ); + }); + + testWidgets('서버가 코드를 요구하면(AUTH_007 — 그사이 다른 곳에서 켬) 코드 칸을 보인다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_007'), + ); + await h.pump(tester); + await submitLogin(tester); + expect(findCopy(PasswordChangeTexts.codeLabel), findsNothing); + + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect(findCopy(message('AUTH_007')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('앱을 다시 열어 쥔 비밀번호가 없으면 임시 비밀번호 칸 — 2단계가 켜졌으면 코드 칸도', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'passwordChange'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + + await h.pump(tester, refreshToken: 'refresh-1'); + + expect(find.byType(PasswordChangePage), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + expect(findCopy(PasswordChangeTexts.temporaryHint), findsOneWidget); + expect(findCopy(PasswordChangeTexts.codeLabel), findsOneWidget); + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.twoFactor).single), + 'access-2', + ); + }); + + testWidgets('쥔 임시 비밀번호는 10분 뒤 지워지고 그 칸이 나타난다', (WidgetTester tester) async { + loginAnswers(restriction: 'passwordChange'); + await h.pump(tester); + await submitLogin(tester); + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsNothing); + + h.clock.advance(const Duration(minutes: 10)); + await tester.pump(const Duration(minutes: 10)); + await tester.pumpAndSettle(); + + expect(findCopy(PasswordChangeTexts.temporaryLabel), findsOneWidget); + }); + + testWidgets('바꿨지만 넘기지 못하면 위쪽 상자 + [다시 시도] — 칸은 막히고, 다시 시도로 넘어간다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect( + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + findsOneWidget, + ); + // 이미 바꿨다 — 다시 보내지 않게 막는다 + final FilledButton submit = tester.widget( + button(PasswordChangeTexts.submit), + ); + expect(submit.onPressed, isNull); + expect( + tester + .widget(fieldInput(PasswordChangeTexts.nextLabel)) + .readOnly, + isTrue, + ); + + // 곧바로 눌러도 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + ); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.server.received('PUT', ApiPaths.password), hasLength(1)); + }); + + testWidgets('REQ_001(시도 과다) — 위쪽 상자, 칸은 그대로 · 알리지 않고 다음에 보낼 때 내린다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + Completer? release; + h.server.on('PUT', ApiPaths.password, (_) async { + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return FakeResponse.ok(); + }); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + await submitLogin(tester); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, PasswordChangeTexts.nextLabel), 'new-pw-1234'); + expect( + tester + .widget(button(PasswordChangeTexts.submit)) + .onPressed, + isNotNull, + ); + expect(h.notifier.shownCodes, isEmpty); + + release = Completer(); + await tester.tap(button(PasswordChangeTexts.submit)); + await tester.pump(Duration.zero); + expect(h.server.received('PUT', ApiPaths.password), hasLength(2)); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('보내는 동안 · 넘기는 동안 [다른 계정으로 로그인]은 누를 수 없다 — 끝나면 다시 누를 수 있다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'passwordChange'); + final Completer changed = Completer(); + final Completer refreshed = Completer(); + h.server.on('PUT', ApiPaths.password, (_) async { + await changed.future; + return FakeResponse.ok(); + }); + h.server.on('POST', ApiPaths.appRefresh, (_) async { + await refreshed.future; + return FakeResponse.fail(503, 'MAIL_001'); + }); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + await submitLogin(tester); + expect(otherAccountEnabled(tester), isTrue); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + + // 보내는 동안 — 서버가 이미 바꿨을 수 있다. + // (Dio 는 요청 차례를 0초 타이머로 넘긴다 — 시간을 주지 않는 pump() 로는 보내지 않는다) + await tester.tap(button(PasswordChangeTexts.submit)); + await tester.pump(Duration.zero); + expect(h.server.received('PUT', ApiPaths.password), hasLength(1)); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(Duration.zero); + + // 넘기는 동안(새 토큰 받기) + changed.complete(); + await tester.pump(Duration.zero); + expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1)); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(); + + refreshed.complete(); + await tester.pumpAndSettle(); + expect(find.byType(PasswordChangePage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + // 넘기지 못해 [다시 시도]가 남았다 — 이제는 다른 계정으로 갈 수 있다 + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect(otherAccountEnabled(tester), isTrue); + }); + + testWidgets('코드 칸의 안내와 오류를 함께 보인다(AUTH_006) — 오류만 나타날 때 읽히고, 둘 다 칸의 설명이다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(restriction: 'passwordChange', twoFactor: true); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + await h.pump(tester); + await submitLogin(tester); + await submitCode(tester, '123456'); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + + final Finder codeField = find.byWidgetPredicate( + (Widget w) => + w is LoginTextField && w.label == PasswordChangeTexts.codeLabel, + ); + expect( + find.descendant( + of: codeField, + matching: findCopy(PasswordChangeTexts.codeHint), + ), + findsOneWidget, + ); + expect( + find.descendant(of: codeField, matching: findCopy(message('AUTH_006'))), + findsOneWidget, + ); + expect( + tester.getSemantics(findCopy(message('AUTH_006'))), + isSemantics(label: message('AUTH_006'), isLiveRegion: true), + ); + expect( + tester.getSemantics(findCopy(PasswordChangeTexts.codeHint)), + isSemantics(label: PasswordChangeTexts.codeHint, isLiveRegion: false), + ); + final String hint = tester + .getSemantics(fieldEditable(PasswordChangeTexts.codeLabel)) + .hint; + expect(hint, contains(PasswordChangeTexts.codeHint)); + expect(hint, contains(message('AUTH_006'))); + expect(h.notifier.shownCodes, isEmpty); + semantics.dispose(); + }); + }); + + group('넷째 단계 — 2단계 인증 켜기', () { + Future openSetup(WidgetTester tester) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await h.pump(tester); + await submitLogin(tester); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + } + + testWidgets('보드의 글 — 키를 받아 4자씩 두 줄로 보이고, 저장될 이름을 보인다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + + for (final String text in [ + ScreenTitles.twoFactorSetup, + TwoFactorSetupTexts.subtitle, + TwoFactorSetupTexts.addTitle, + TwoFactorSetupTexts.addHint, + TwoFactorSetupTexts.keyLabel, + TwoFactorSetupTexts.savedAs(kTestEmail), + TwoFactorSetupTexts.codeLabel, + TwoFactorSetupTexts.codeHint, + SupportLineTexts.authenticatorFallback, + TwoFactorSetupTexts.note, + ]) { + expect(findCopy(text), findsOneWidget, reason: text); + } + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + expect( + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + findsOneWidget, + ); + // 같은 휴대폰이라 키를 띄우자마자 칸에 초점을 주지 않는다([인증 앱에 추가]가 자판에 가리지 않게) + expect( + tester + .widget(fieldInput(TwoFactorSetupTexts.codeLabel)) + .focusNode + ?.hasFocus, + isFalse, + ); + }); + + testWidgets('[인증 앱에 추가]는 otpauth 주소로 인증 앱을 연다 — 찾지 못하면 안내 글', ( + WidgetTester tester, + ) async { + await openSetup(tester); + + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + ); + expect(h.launcher.opened, [kTestOtpauthUrl]); + expect(findCopy(TwoFactorSetupTexts.addFailed), findsNothing); + + h.launcher.opens = false; + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.addButton), + ); + expect(findCopy(TwoFactorSetupTexts.addFailed), findsOneWidget); + }); + + testWidgets('[복사]는 키를 띄어쓰기 없이 클립보드에 두고 알린다', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await openSetup(tester); + final Finder copy = find.widgetWithText( + TextButton, + TwoFactorSetupTexts.copy, + ); + expect( + tester.getSemantics(copy), + isSemantics(label: TwoFactorSetupTexts.copyLabel, isButton: true), + ); + + await tapAndSettle(tester, copy); + + expect(h.clipboard, [kTestSecret]); + expect(find.text(TwoFactorSetupTexts.copied), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('켜면 새 토큰을 받아 메일로 — 알림, 쥔 비밀번호로 보냈다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).single), + { + 'enrollmentId': 'enroll-1', + 'otpCode': '123456', + 'currentPassword': 'temp-pw-1', + }, + ); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.done), findsOneWidget); + }); + + testWidgets('AUTH_006 — 코드 칸 아래 · 칸을 비운다, 키는 그대로(다시 받지 않는다)', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '000000', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), isEmpty); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('AUTH_008(시간 지남 · 그사이 바뀜) — 위쪽 상자 + 새 키', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: false), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(id: 'enroll-2'), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('AUTH_008')), findsOneWidget); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(2), + ); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), isEmpty); + + // 새 등록으로 켠다 + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect( + bodyOf(h.server.received('POST', ApiPaths.twoFactorActivation).last), + containsPair('enrollmentId', 'enroll-2'), + ); + }); + + testWidgets('AUTH_008 인데 그사이 다른 곳에서 켰으면 이 단계를 건너뛴다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('켰지만 넘기지 못하면 위쪽 상자와 [다시 시도]만 — 키 · 칸 · 지금 비밀번호는 다시 보이지 않는다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect(findCopy(TwoFactorSetupTexts.addTitle), findsNothing); + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsNothing); + expect(button(TwoFactorSetupTexts.submit), findsNothing); + + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, RestrictedStepTexts.retry), + ); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.done), findsOneWidget); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(1), + ); + }); + + testWidgets('키를 받지 못하면 사전 문구 + [다시 시도] — 켜기는 누를 수 없다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await h.pump(tester); + await submitLogin(tester); + + expect(findCopy(message('MAIL_001')), findsOneWidget); + expect( + tester + .widget(button(TwoFactorSetupTexts.submit)) + .onPressed, + isNull, + ); + // 키 자리의 실패는 화면이 보인다 — 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await tapAndSettle( + tester, + find.widgetWithText(OutlinedButton, TwoFactorSetupTexts.retry), + ); + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + }); + + testWidgets('쥔 비밀번호가 없으면(앱을 다시 열었다) ③ 지금 비밀번호 — 비우고 보내면 안내 글이 빨강', ( + WidgetTester tester, + ) async { + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + await h.pump(tester, refreshToken: 'refresh-1'); + expect(findCopy(TwoFactorSetupTexts.passwordLabel), findsOneWidget); + + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(h.server.received('POST', ApiPaths.twoFactorActivation), isEmpty); + final Text hint = tester.widget( + findCopy(TwoFactorSetupTexts.passwordHint), + ); + expect( + hint.style?.color, + Theme.of( + tester.element(findCopy(TwoFactorSetupTexts.passwordHint)), + ).colorScheme.error, + ); + }); + + testWidgets('REQ_001(시도 과다) — 위쪽 상자, 코드 · 키는 그대로 · 알리지 않고 다음에 보낼 때 내린다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + Completer? release; + h.server.on('POST', ApiPaths.twoFactorActivation, (_) async { + final Completer? waiting = release; + if (waiting == null) return FakeResponse.fail(429, 'REQ_001'); + await waiting.future; + return twoFactorStatusResponse(enabled: true); + }); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(alertWith(message('REQ_001')), findsOneWidget); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), '123456'); + expect( + find.text('K7QF 2M4X HB6R TZ3N\nPW5C YL7D JQ2V MGAS'), + findsOneWidget, + ); + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + + release = Completer(); + await tester.tap(button(TwoFactorSetupTexts.submit)); + await tester.pump(Duration.zero); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(2), + ); + expect(alertWith(message('REQ_001')), findsNothing); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('서버의 VAL_001 은 전역 알림 — 맞는 코드는 지우지 않는다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'VAL_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(h.notifier.shownCodes, ['VAL_001']); + expect(fieldText(tester, TwoFactorSetupTexts.codeLabel), '123456'); + expect(findCopy(message('VAL_001')), findsNothing); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + }); + + testWidgets('켜는 동안 [다른 계정으로 로그인]은 누를 수 없다 — 끝나면 다시 누를 수 있다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + final Completer release = Completer(); + h.server.on('POST', ApiPaths.twoFactorActivation, (_) async { + await release.future; + return FakeResponse.fail(400, 'AUTH_006'); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + expect(otherAccountEnabled(tester), isTrue); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tester.tap(button(TwoFactorSetupTexts.submit)); + await tester.pump(Duration.zero); + expect( + h.server.received('POST', ApiPaths.twoFactorActivation), + hasLength(1), + ); + expect(otherAccountEnabled(tester), isFalse); + await tester.tap(otherAccount()); + await tester.pump(); + + release.complete(); + await tester.pumpAndSettle(); + expect(find.byType(TwoFactorSetupPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + expect(findCopy(message('AUTH_006')), findsOneWidget); + expect(otherAccountEnabled(tester), isTrue); + }); + + testWidgets('[복사]가 실패하면 안내 글로 알린다 — 키를 보고 직접 넣을 수 있다', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.clipboardFails = true; + + await tapAndSettle( + tester, + find.widgetWithText(TextButton, TwoFactorSetupTexts.copy), + ); + + expect(h.clipboard, isEmpty); + expect(find.text(TwoFactorSetupTexts.copyFailed), findsOneWidget); + expect(find.text(TwoFactorSetupTexts.copied), findsNothing); + }); + + testWidgets('키를 받을 때 AUTH_008 인데 그사이 다른 곳에서 켰으면 이 단계를 건너뛴다', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => twoFactorStatusResponse(enabled: true), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.server.received('POST', ApiPaths.twoFactorActivation), isEmpty); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('켜는 사이 다른 곳에서 켜 이 세션이 끊겼으면(401 · 갱신 401) 로그인 화면 + AUTH_002', ( + WidgetTester tester, + ) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets( + 'AUTH_008 뒤 상태를 묻는 사이 세션이 끊겼으면(다른 곳에서 켬 — 401 · 갱신 401) 로그인 화면 + AUTH_002', + (WidgetTester tester) async { + await openSetup(tester); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(409, 'AUTH_008'), + ); + h.server.on( + 'GET', + ApiPaths.twoFactor, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '123456', + ); + + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + + expect(find.byType(LoginPage), findsOneWidget); + expect(alertWith(message('AUTH_002')), findsOneWidget); + // 끊긴 세션으로 새 키를 받으러 가지 않았다 + expect( + h.server.received('POST', ApiPaths.twoFactorEnrollments), + hasLength(1), + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + }); + + group('모르는 제한 · 화면 낭독기 · 비밀', () { + testWidgets('모르는 제한 — AUTH_010 사전 문구와 [다른 계정으로 로그인]만', ( + WidgetTester tester, + ) async { + loginAnswers(restriction: 'somethingNew'); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await h.pump(tester); + + await submitLogin(tester); + + expect(find.byType(RestrictedLoginPage), findsOneWidget); + expect(findCopy(message('AUTH_010')), findsOneWidget); + expect(find.text(kTestEmail), findsOneWidget); + + await tapAndSettle(tester, otherAccount()); + expect(find.byType(LoginPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + }); + + testWidgets('단계의 제목은 머리글이자 화면 이름 — 단계가 바뀌면 낭독기가 새 제목을 읽는다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(twoFactor: true, restriction: 'passwordChange'); + await h.pump(tester); + expect( + tester.getSemantics(heading(ScreenTitles.login)), + isSemantics( + label: ScreenTitles.login, + isHeader: true, + namesRoute: true, + ), + ); + + await submitLogin(tester); + expect( + tester.getSemantics(heading(ScreenTitles.loginCode)), + isSemantics( + label: ScreenTitles.loginCode, + isHeader: true, + namesRoute: true, + ), + ); + + await submitCode(tester, '123456'); + expect( + tester.getSemantics(heading(ScreenTitles.passwordChange)), + isSemantics( + label: ScreenTitles.passwordChange, + isHeader: true, + namesRoute: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('칸 이름은 칸에 붙어 읽히고, 칸 아래 오류는 나타날 때 읽힌다(live region)', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + loginAnswers(twoFactor: true); + await h.pump(tester); + expect( + tester + .getSemantics( + find.descendant( + of: fieldInput(LoginTexts.emailLabel), + matching: find.byType(EditableText), + ), + ) + .label, + startsWith(LoginTexts.emailLabel), + ); + + await submitLogin(tester); + await submitCode(tester, '12'); + + expect( + tester.getSemantics(findCopy(message('AUTH_007'))), + isSemantics(label: message('AUTH_007'), isLiveRegion: true), + ); + // 칸에 다시 와도 오류가 함께 읽힌다(칸의 설명 — 웹 aria-describedby) + expect( + tester.getSemantics(fieldEditable(LoginCodeTexts.codeLabel)).hint, + contains(message('AUTH_007')), + ); + semantics.dispose(); + }); + + testWidgets('비밀(비밀번호 · 코드 · 키 · otpauth 주소)은 어떤 프로바이더 상태에도 두지 않는다', ( + WidgetTester tester, + ) async { + final _ValueRecorder recorder = _ValueRecorder(); + loginAnswers(twoFactor: true, restriction: 'passwordChange'); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + int refreshes = 0; + h.server.on('POST', ApiPaths.appRefresh, (_) { + refreshes++; + return refreshResponse( + refreshes + 1, + now: h.clock.now, + restriction: refreshes == 1 ? 'twoFactorSetup' : null, + ); + }); + h.server.on( + 'POST', + ApiPaths.twoFactorEnrollments, + (_) => enrollmentResponse(), + ); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + await h.pump(tester, observers: [recorder]); + + await submitLogin(tester); + await submitCode(tester, '123456'); + await tester.enterText( + fieldInput(PasswordChangeTexts.nextLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.confirmLabel), + 'new-pw-1234', + ); + await tester.enterText( + fieldInput(PasswordChangeTexts.codeLabel), + '654321', + ); + await tapAndSettle(tester, button(PasswordChangeTexts.submit)); + await tapAndSettle( + tester, + find.widgetWithText(TextButton, TwoFactorSetupTexts.copy), + ); + await tester.enterText( + fieldInput(TwoFactorSetupTexts.codeLabel), + '112233', + ); + await tapAndSettle(tester, button(TwoFactorSetupTexts.submit)); + expect(find.byType(NavigationBar), findsOneWidget); + + expect(recorder.records, isNotEmpty); + for (final ({String provider, Object? value}) record + in recorder.records) { + final Object? recorded = record.value; + // 이름으로도 본다 — AsyncData · List 처럼 감싸여도 잡는다 + expect( + recorded.runtimeType.toString(), + isNot(contains('TwoFactorEnrollment')), + reason: record.provider, + ); + // AsyncValue 는 속의 값 · 오류를 꺼내 본다(비밀이 AsyncData 안에 숨지 않게) + final Object? value = recorded is AsyncValue + ? recorded.value + : recorded; + final Object? error = recorded is AsyncValue + ? recorded.error + : null; + expect( + value.runtimeType.toString(), + isNot(contains('TwoFactorEnrollment')), + reason: record.provider, + ); + expect( + _allowedProviderValue(value), + isTrue, + reason: '${record.provider} → ${value.runtimeType}', + ); + for (final Object? shown in [value, error]) { + final String text = shown.toString(); + for (final String secret in [ + 'temp-pw-1', + 'new-pw-1234', + '123456', + '654321', + '112233', + kTestSecret, + 'otpauth', + ]) { + expect(text, isNot(contains(secret)), reason: record.provider); + } + } + } + }); + }); +} + +/// 프로바이더에 들어가도 되는 값의 모양 — 지금 앱의 프로바이더 값 전부. 새 모양이 생기면 이 시험이 알려 준다 +/// (비밀 — 비밀번호 · 코드 · 2단계 키 · otpauth 주소 — 을 담을 수 있는 모양인지 사람이 보고 더한다) +bool _allowedProviderValue(Object? value) => + value == null || + // 문의 연락처 · User-Agent · 앱 판 — 글은 위에서 비밀이 없는지 본다 + value is String || + value is SessionState || + // 시계 + value is Clock || + value is ApiConfig || + value is SessionEventBus || + value is GlobalKey || + value is ErrorNotifier || + value is HttpClientAdapter || + value is DioClient || + value is Dio || + value is TokenRefresher || + value is TokenStore || + value is PushRegistrar || + value is List || + value is GoRouter || + value is AuthRepository || + value is TwoFactorRepository || + value is SupportContactRepository || + value is AuthenticatorLauncher; + +/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 +final class _ValueRecorder extends ProviderObserver { + final List<({String provider, Object? value})> records = + <({String provider, Object? value})>[]; + + void _add(ProviderObserverContext context, Object? value) { + records.add((provider: '${context.provider}', value: value)); + } + + @override + void didAddProvider(ProviderObserverContext context, Object? value) { + _add(context, value); + } + + @override + void didUpdateProvider( + ProviderObserverContext context, + Object? previousValue, + Object? newValue, + ) { + _add(context, newValue); + } + + @override + void providerDidFail( + ProviderObserverContext context, + Object error, + StackTrace stackTrace, + ) { + _add(context, AsyncError(error, stackTrace)); + } +} diff --git a/flutter/test/features/auth/login_secret_test.dart b/flutter/test/features/auth/login_secret_test.dart new file mode 100644 index 0000000..e71e51b --- /dev/null +++ b/flutter/test/features/auth/login_secret_test.dart @@ -0,0 +1,47 @@ +import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; + +void main() { + group('로그인 때 넣은 비밀번호 쥐기', () { + test('쥐고 있는지를 알린다 — 값(비밀번호)이 아니라 참 · 거짓만', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + final List seen = []; + secret.held.addListener(() => seen.add(secret.held.value)); + + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + secret.clear(); + + expect(seen, [true, false]); + expect(secret.read(), isNull); + }); + + test('10분이 지나면 읽을 때 시계로 다시 보고 지운다 — 쥐고 있지 않다고 알린다', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + + clock.advance(const Duration(minutes: 9, seconds: 59)); + expect(secret.read()?.password, 'temp-pw'); + + clock.advance(const Duration(seconds: 1)); + expect(secret.read(), isNull); + expect(secret.held.value, isFalse); + expect(secret.isHeld, isFalse); + }); + + test('서버가 다시 확인하면(renew) 10분을 그때부터 다시 센다', () { + final ManualClock clock = ManualClock(); + final LoginSecret secret = LoginSecret(clock: clock.call); + secret.hold('ara.kim@arasystem.it.kr', 'temp-pw'); + + clock.advance(const Duration(minutes: 8)); + secret.renew(); + clock.advance(const Duration(minutes: 8)); + + expect(secret.read()?.password, 'temp-pw'); + }); + }); +} diff --git a/flutter/test/features/auth/restricted_steps_test.dart b/flutter/test/features/auth/restricted_steps_test.dart new file mode 100644 index 0000000..8e39e66 --- /dev/null +++ b/flutter/test/features/auth/restricted_steps_test.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/provider_harness.dart'; + +/// 세션 노티파이어의 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기 · 다음으로 넘기기) — 계약 5 · 6절 +void main() { + late ProviderHarness h; + late ProviderContainer container; + + Future start() async { + await h.prepare(); + container = h.container(); + addTearDown(container.dispose); + container.listen(sessionProvider, (_, _) {}); + await container.read(sessionProvider.future); + } + + SessionNotifier notifier() => container.read(sessionProvider.notifier); + + SessionState? current() => container.read(sessionProvider).value; + + SessionRestriction? restrictionNow() { + final SessionState? state = current(); + return state is SignedIn ? state.info.restriction : null; + } + + Map bodyOf(RequestOptions options) => + options.data! as Map; + + /// 제한된 세션으로 로그인한다 — 코드 단계를 거치면 [viaCode] + Future signInRestricted( + String restriction, { + bool viaCode = false, + }) async { + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + if (viaCode && bodyOf(options)['otpCode'] == null) { + return codeStepResponse(); + } + return signedInResponse(1, now: h.clock.now, restriction: restriction); + }); + await notifier().signIn(email: kTestEmail, password: 'temp-pw-1'); + if (viaCode) await notifier().submitCode('123456'); + expect(current(), isA()); + } + + setUp(() => h = ProviderHarness()); + + group('코드 단계', () { + test('코드를 실었는데도 코드를 묻으면 AUTH_006 으로 던지고 코드 단계에 남는다', () async { + await start(); + h.server.on('POST', ApiPaths.appLogin, (_) => codeStepResponse()); + await notifier().signIn(email: kTestEmail, password: 'pw'); + + final Object error = await errorOf(notifier().submitCode('123456')); + + expect(apiErrorOf(error).code, 'AUTH_006'); + expect(current(), const CodeRequired(kTestEmail)); + expect(notifier().heldLogin?.password, 'pw'); + }); + + test('그사이 비밀번호가 바뀌었으면(AUTH_004) 주소를 남긴 첫 단계로 돌아간다', () async { + await start(); + h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) { + return bodyOf(options)['otpCode'] == null + ? codeStepResponse() + : FakeResponse.fail(401, 'AUTH_004'); + }); + await notifier().signIn(email: kTestEmail, password: 'pw'); + + final Object error = await errorOf(notifier().submitCode('123456')); + + expect(apiErrorOf(error).code, 'AUTH_004'); + expect( + current(), + const SignedOut( + reason: SignOutReason.passwordRejected, + email: kTestEmail, + ), + ); + expect(notifier().heldLogin, isNull); + // 칸에 보이는 실패라 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }); + + test('코드 단계를 거쳐 로그인했는지 기억한다 — 새 로그인 · 다른 계정이면 지운다', () async { + await start(); + await signInRestricted('passwordChange', viaCode: true); + expect(notifier().passedCodeStep, isTrue); + + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await notifier().switchAccount(); + expect(notifier().passedCodeStep, isFalse); + + await signInRestricted('passwordChange'); + expect(notifier().passedCodeStep, isFalse); + }); + }); + + group('새 비밀번호 단계 (changePassword)', () { + test('쥔 임시 비밀번호를 지금 비밀번호로 보내고, 바뀌면 새 비밀번호를 쥔다', () async { + await start(); + await signInRestricted('passwordChange', viaCode: true); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + otpCode: '654321', + ); + + expect(result, RestrictedStepResult.done); + final RequestOptions sent = h.server + .received('PUT', ApiPaths.password) + .single; + expect(bodyOf(sent), { + 'currentPassword': 'temp-pw-1', + 'newPassword': 'new-pw-1234', + 'otpCode': '654321', + }); + expect(bearerTokenOf(sent), 'access-1'); + // 서버가 엔진을 두 번 부른다 — 받기 한도 30초 + expect(sent.receiveTimeout, const Duration(seconds: 30)); + // 다음 단계(2단계 켜기)의 지금 비밀번호 + expect(notifier().heldLogin?.password, 'new-pw-1234'); + expect(notifier().heldLoginListenable.value, isTrue); + }); + + test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 실패 예외에는 본문이 없고 알리지 않는다', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'PUT', + ApiPaths.password, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + + final Object error = await errorOf( + notifier().changePassword(newPassword: 'new-pw-1234'), + ); + + expect(apiErrorOf(error).code, 'AUTH_005'); + expect((error as DioException).requestOptions.data, isNull); + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('쥔 비밀번호가 없고 칸의 값도 주지 않으면 보내지 않는다(needsPassword)', () async { + await start(); + await signInRestricted('passwordChange'); + h.clock.advance(const Duration(minutes: 10)); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + ); + + expect(result, RestrictedStepResult.needsPassword); + expect(h.server.received('PUT', ApiPaths.password), isEmpty); + }); + + test('칸에 넣은 임시 비밀번호로 보낼 수 있다(앱을 다시 열었다)', () async { + await start(); + await signInRestricted('passwordChange'); + h.clock.advance(const Duration(minutes: 10)); + h.server.on('PUT', ApiPaths.password, (_) => FakeResponse.ok()); + + final RestrictedStepResult result = await notifier().changePassword( + newPassword: 'new-pw-1234', + currentPassword: 'typed-temp', + ); + + expect(result, RestrictedStepResult.done); + expect( + bodyOf(h.server.received('PUT', ApiPaths.password).single), + { + 'currentPassword': 'typed-temp', + 'newPassword': 'new-pw-1234', + }, + ); + }); + + test('보내는 사이 [다른 계정으로 로그인]하면 결과를 쓰지 않는다(stale) — 새 비밀번호를 쥐지 않는다', () async { + await start(); + await signInRestricted('passwordChange'); + final Completer release = Completer(); + h.server.on('PUT', ApiPaths.password, (_) async { + await release.future; + return FakeResponse.ok(); + }); + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + + final Future changing = notifier().changePassword( + newPassword: 'new-pw-1234', + ); + await pumpEventsUntil( + () => h.server.received('PUT', ApiPaths.password).isNotEmpty, + reason: '비밀번호 바꾸기가 서버에 닿기', + ); + await notifier().switchAccount(); + release.complete(); + + expect(await changing, RestrictedStepResult.stale); + expect(notifier().heldLogin, isNull); + expect(current(), const SignedOut(reason: SignOutReason.switchAccount)); + }); + }); + + group('2단계 켜기 (enableTwoFactor)', () { + test('등록 id · 코드 · 쥔 비밀번호를 보내고, 켜면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => twoFactorStatusResponse(enabled: true), + ); + + final RestrictedStepResult result = await notifier().enableTwoFactor( + enrollmentId: 'enroll-1', + otpCode: '123456', + ); + + expect(result, RestrictedStepResult.done); + final RequestOptions sent = h.server + .received('POST', ApiPaths.twoFactorActivation) + .single; + expect(bodyOf(sent), { + 'enrollmentId': 'enroll-1', + 'otpCode': '123456', + 'currentPassword': 'temp-pw-1', + }); + expect(sent.receiveTimeout, const Duration(seconds: 30)); + expect(notifier().heldLogin, isNull); + }); + + test('쥔 비밀번호를 서버가 거절하면(AUTH_005) 지운다 — 화면이 지금 비밀번호 칸을 보인다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_005'), + ); + + final Object error = await errorOf( + notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '123456'), + ); + + expect(apiErrorOf(error).code, 'AUTH_005'); + expect((error as DioException).requestOptions.data, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('코드가 틀리면(AUTH_006) 쥔 비밀번호는 그대로다(코드만 다시 넣는다)', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.twoFactorActivation, + (_) => FakeResponse.fail(400, 'AUTH_006'), + ); + + await errorOf( + notifier().enableTwoFactor(enrollmentId: 'enroll-1', otpCode: '000000'), + ); + + expect(notifier().heldLogin?.password, 'temp-pw-1'); + }); + }); + + group('모르는 제한 — 로그인 때 넣은 비밀번호를 쥐지 않는다(웹 onSignedIn 과 같다)', () { + test('모르는 제한으로 로그인하면 쓸 단계가 없어 쥐지 않는다', () async { + await start(); + + await signInRestricted('somethingNew'); + + expect(restrictionNow(), SessionRestriction.unknown); + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + }); + + test('갱신 응답으로 제한이 모르는 제한이 되면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('passwordChange'); + expect(notifier().heldLogin, isNotNull); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'somethingNew'), + ); + + await container.read(authRepositoryProvider).refresh(); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.unknown, + reason: '갱신 응답의 제한 반영', + ); + + expect(notifier().heldLogin, isNull); + expect(notifier().heldLoginListenable.value, isFalse); + }); + + test('다시 맞추기(AUTH_010)로 모르는 제한이 되면 쥔 비밀번호를 지운다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'GET', + '/mailboxes', + (_) => FakeResponse.fail(403, 'AUTH_010'), + ); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'somethingNew')), + ); + + await errorOf(container.read(dioProvider).get('/mailboxes')); + + expect(restrictionNow(), SessionRestriction.unknown); + expect(notifier().heldLogin, isNull); + }); + + test('제한이 새 비밀번호 → 2단계 켜기로 바뀌면 쥔 비밀번호는 그대로다(다음 단계가 쓴다)', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + + await container.read(authRepositoryProvider).refresh(); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.twoFactorSetup, + reason: '갱신 응답의 제한 반영', + ); + + expect(notifier().heldLogin?.password, 'temp-pw-1'); + }); + }); + + group('다음 단계로 넘기기 (finishRestrictedStep)', () { + test('새 토큰의 세션 칸으로 다음 제한(2단계 켜기)으로 옮긴다', () async { + await start(); + await signInRestricted('passwordChange'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => + refreshResponse(2, now: h.clock.now, restriction: 'twoFactorSetup'), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + expect(h.store.accessToken, 'access-2'); + // 2단계 켜기가 지금 비밀번호로 쓴다 + expect(notifier().heldLogin, isNotNull); + expect(h.push.registered, 0); + }); + + test('제한이 풀리면 메일로 — 쥔 비밀번호를 지우고 알림 기기를 등록한다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => refreshResponse(2, now: h.clock.now), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(current(), isA()); + expect(restrictionNow(), isNull); + expect(notifier().heldLogin, isNull); + expect(h.push.registered, 1); + }); + + test('갱신이 장애면 세션 확인(me)의 지금 값으로 정한다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.done, + ); + + expect(restrictionNow(), isNull); + expect(current(), isA()); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('갱신도 세션 확인도 못 하면 갱신의 실패로 던지고 그 단계에 머문다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.offline); + + final Object error = await errorOf(notifier().finishRestrictedStep()); + + expect(apiErrorOf(error).code, 'MAIL_001'); + expect(restrictionNow(), SessionRestriction.twoFactorSetup); + // 화면이 위쪽 상자에 보인다 — 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }); + + test('갱신이 세션을 끝냈으면(401) stale — 로그인 화면으로 옮긴다', () async { + await start(); + await signInRestricted('twoFactorSetup'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + expect( + await notifier().finishRestrictedStep(), + RestrictedStepResult.stale, + ); + await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝'); + expect(current(), const SignedOut(reason: SignOutReason.expired)); + }); + }); +} diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 9f0a25f..4e7f5f8 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -180,7 +180,14 @@ void main() { h.clock.advance(const Duration(minutes: 10)); await notifier().submitCode('123456'); - expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut)); + // 첫 단계가 주소를 남기고 위쪽 상자에 까닭을 보인다 + expect( + current(), + const SignedOut( + reason: SignOutReason.codeTimedOut, + email: 'ara.kim@arasystem.it.kr', + ), + ); expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1)); }); @@ -350,7 +357,8 @@ void main() { await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림'); expect(current(), const SignedOut(reason: SignOutReason.expired)); - expect(h.notifier.shownCodes, ['AUTH_002']); + // 로그인 화면의 위쪽 상자가 알린다 — 스낵바는 띄우지 않는다 + expect(h.notifier.shownCodes, isEmpty); }); test( diff --git a/flutter/test/features/auth/two_factor_parts_test.dart b/flutter/test/features/auth/two_factor_parts_test.dart new file mode 100644 index 0000000..5668382 --- /dev/null +++ b/flutter/test/features/auth/two_factor_parts_test.dart @@ -0,0 +1,159 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/provider_harness.dart'; + +void main() { + group('2단계 등록 모델', () { + test('응답을 읽고, toString 은 키 · otpauth 주소를 가린다', () { + final TwoFactorEnrollment? enrollment = + TwoFactorEnrollment.tryParse({ + 'enrollmentId': 'enroll-1', + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + 'expiresAt': '2026-11-05T03:10:00.000Z', + }); + + expect(enrollment?.enrollmentId, 'enroll-1'); + expect(enrollment?.secret, kTestSecret); + expect(enrollment?.expiresAt, DateTime.utc(2026, 11, 5, 3, 10)); + expect(enrollment.toString(), isNot(contains(kTestSecret))); + expect(enrollment.toString(), isNot(contains('otpauth'))); + }); + + test('칸이 비었거나 otpauth 주소가 아니면 읽지 않는다', () { + expect(TwoFactorEnrollment.tryParse(null), isNull); + expect( + TwoFactorEnrollment.tryParse({ + 'enrollmentId': 'enroll-1', + 'secret': kTestSecret, + 'otpauthUrl': 'https://evil.example/otp', + }), + isNull, + ); + expect( + TwoFactorEnrollment.tryParse({ + 'enrollmentId': '', + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + }), + isNull, + ); + }); + + test('상태는 isEnabled 만 본다 — 없으면 읽지 않는다', () { + expect( + TwoFactorStatus.tryParse({ + 'isEnabled': true, + })?.isEnabled, + isTrue, + ); + expect(TwoFactorStatus.tryParse({}), isNull); + }); + }); + + group('[인증 앱에 추가]', () { + test('otpauth 주소를 브라우저가 아닌 앱으로 연다', () async { + final List<(Uri, LaunchMode)> calls = <(Uri, LaunchMode)>[]; + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async { + calls.add((url, mode)); + return true; + }, + ); + + expect(await launcher.open(kTestOtpauthUrl), isTrue); + expect(calls.single.$1.toString(), kTestOtpauthUrl); + expect(calls.single.$2, LaunchMode.externalNonBrowserApplication); + }); + + test('받을 앱이 없어 플러그인이 던지면(문장에 키가 든다) 삼키고 false', () async { + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async => throw PlatformException( + code: 'ACTIVITY_NOT_FOUND', + message: 'No Activity found to handle intent { $url }', + ), + ); + + expect(await launcher.open(kTestOtpauthUrl), isFalse); + }); + + test('otpauth 주소가 아니면 열지 않는다', () async { + int calls = 0; + final AuthenticatorLauncher launcher = AuthenticatorLauncher( + launch: (Uri url, LaunchMode mode) async { + calls++; + return true; + }, + ); + + expect(await launcher.open('https://evil.example/otp'), isFalse); + expect(calls, 0); + }); + }); + + group('문의 연락처', () { + test('한 번 받으면 다시 묻지 않고, 다듬은 한 줄을 둔다', () async { + final ProviderHarness h = ProviderHarness(); + await h.prepare(); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({ + 'text': ' IT 담당 홍길동\n02-000-0000 ', + }), + ); + final SupportContactNotifier notifier = container.read( + supportContactProvider.notifier, + ); + + await Future.wait(>[ + notifier.ensureLoaded(), + notifier.ensureLoaded(), + ]); + await notifier.ensureLoaded(); + + expect(container.read(supportContactProvider), 'IT 담당 홍길동 02-000-0000'); + expect(h.server.received('GET', ApiPaths.supportContact), hasLength(1)); + // 로그인 없이 부른다 — 토큰을 싣지 않는다 + expect( + bearerTokenOf(h.server.received('GET', ApiPaths.supportContact).single), + isNull, + ); + }); + + test('받지 못하면 알리지 않고 null — 다음에 다시 묻는다', () async { + final ProviderHarness h = ProviderHarness(); + await h.prepare(); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + h.server.on('GET', ApiPaths.supportContact, (_) => FakeResponse.offline); + final SupportContactNotifier notifier = container.read( + supportContactProvider.notifier, + ); + + await notifier.ensureLoaded(); + expect(container.read(supportContactProvider), isNull); + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': '사내 메신저 @it'}), + ); + await notifier.ensureLoaded(); + expect(container.read(supportContactProvider), '사내 메신저 @it'); + }); + }); +} diff --git a/flutter/test/support/login_fakes.dart b/flutter/test/support/login_fakes.dart new file mode 100644 index 0000000..d4ece5a --- /dev/null +++ b/flutter/test/support/login_fakes.dart @@ -0,0 +1,183 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'fakes.dart'; +import 'provider_harness.dart'; + +/// 시험 직원의 주소 +const String kTestEmail = 'ara.kim@arasystem.it.kr'; + +/// 시험 2단계 키(32자 base32)와 otpauth 주소 +const String kTestSecret = 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'; +const String kTestOtpauthUrl = + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=$kTestSecret&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30'; + +/// 코드 단계로 답하는 로그인 (계약 5절 — `email` · `name` 은 null, 토큰 없음) +FakeResponse codeStepResponse() => FakeResponse.ok({ + 'status': 'twoFactorRequired', + 'email': null, + 'name': null, + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': null, +}); + +/// 로그인을 마친 응답 ([n] 번째 토큰) +FakeResponse signedInResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + 'status': 'signedIn', + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 갱신 응답 ([n] 번째 토큰 · 세션 칸) +FakeResponse refreshResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 2단계 등록 응답 ([id] 번째 등록 — 키는 같다) +FakeResponse enrollmentResponse({String id = 'enroll-1'}) => + FakeResponse(201, { + 'success': true, + 'data': { + 'enrollmentId': id, + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + 'expiresAt': '2026-11-05T03:10:00.000Z', + }, + }); + +/// 2단계 상태 응답 +FakeResponse twoFactorStatusResponse({required bool enabled}) => + FakeResponse.ok({ + 'isEnabled': enabled, + 'enabledAt': null, + 'isRequired': true, + 'deadline': null, + }); + +/// 보이는 글로 찾는다 — 낱말 가운데서 줄을 바꾸지 않는 글(`KeepAllText`)은 원래 글(화면 낭독기 글)로 찾는다 +Finder findCopy(String text) => find.byWidgetPredicate( + (Widget widget) => + widget is Text && (widget.data == text || widget.semanticsLabel == text), +); + +/// 칸 이름으로 그 칸의 입력을 찾는다 +Finder fieldInput(String label) => find.descendant( + of: find.byWidgetPredicate( + (Widget widget) => widget is LoginTextField && widget.label == label, + ), + matching: find.byType(TextField), +); + +/// 칸 이름으로 그 칸의 컨트롤러 글을 읽는다 +String fieldText(WidgetTester tester, String label) => + tester.widget(fieldInput(label)).controller!.text; + +/// [인증 앱에 추가]를 적어 두는 가짜 — [opens] 가 false 면 받을 앱이 없는 것처럼 답한다 +class FakeAuthenticatorLauncher extends AuthenticatorLauncher { + FakeAuthenticatorLauncher() : super(launch: _never); + + bool opens = true; + final List opened = []; + + static Future _never(Uri url, LaunchMode mode) async => false; + + @override + Future open(String otpauthUrl) async { + opened.add(otpauthUrl); + return opens; + } +} + +/// 로그인 틀 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계 · 가짜 인증 앱 열기로 앱 전체를 띄운다 +class LoginHarness { + LoginHarness(); + + final ProviderHarness base = ProviderHarness(); + final FakeAuthenticatorLauncher launcher = FakeAuthenticatorLauncher(); + + FakeServer get server => base.server; + ManualClock get clock => base.clock; + RecordingNotifier get notifier => base.notifier; + + /// 클립보드에 쓴 글 (가짜 플랫폼 통로) + final List clipboard = []; + + /// 켜면 클립보드 쓰기가 실패한다(플랫폼 예외) + bool clipboardFails = false; + + /// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답해 화면은 지금 글 그대로다 + void supportContact(String? text) { + server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': text}), + ); + } + + /// 앱을 띄운다 — [refreshToken] 이 있으면 앱을 다시 연 것처럼 세션을 되살린다. + /// 화면은 휴대폰 폭(390)에 세로로 길게 — 아래쪽 단추까지 스크롤 없이 누른다 + Future pump( + WidgetTester tester, { + String? refreshToken, + List observers = const [], + }) async { + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await base.prepare(refreshToken: refreshToken); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + if (clipboardFails) { + throw PlatformException(code: 'test', message: '클립보드 쓰기 실패(시험)'); + } + final Object? arguments = call.arguments; + if (arguments is Map) { + clipboard.add(arguments['text']! as String); + } + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + observers: observers, + overrides: [ + ...base.overrides, + authenticatorLauncherProvider.overrideWithValue(launcher), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(App))); + } +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 0c50753..2048c45 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index d0b33f8..e17c858 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST -- 2.52.0 From 4e779dedd2032feaacb5f090df397967e02c6d32 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 15:57:39 +0900 Subject: [PATCH 03/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=84=A4=20=EB=8B=A8=EA=B3=84=EC=9D=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=98=EB=A9=B0=20=EC=A0=95=ED=95=9C=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EC=95=B1=20=EB=94=94=EC=9E=90=EC=9D=B8=20README=20?= =?UTF-8?q?=C2=B7=20flutter=20=EC=8A=A4=ED=82=AC=EC=97=90=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 디자인 README: 단계마다 실패를 보이는 곳(표), 안내와 오류 함께, 세션 끝은 상자만, [다른 계정으로 로그인]을 막는 때, 2단계 켜기의 처음 초점 · 켠 뒤 알림, 다른 곳에서 켠 경우, 주소 칸 자판, 글자 크기 맞춤, 새 글, 알려진 한계(인증 앱에 다녀오는 사이 앱이 정리되면 새 키 · 최근 앱 미리 보기 · 클립보드 · iOS) - flutter 스킬 골격 표에 로그인 화면 줄(노티파이어 제한 단계 API · 알림 끄는 묶음 · url_launcher 쓰는 규칙 · KeepAllText) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 1 + docs/design/app/README.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index a1b557e..441203f 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -23,6 +23,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) | | **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | | 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | +| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index f1729f2..515e24b 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -29,6 +29,26 @@ - 2단계 켜기는 같은 휴대폰이라 QR 을 찍을 수 없다 — [인증 앱에 추가](인증 앱을 바로 연다) · 키와 [복사](키를 직접 넣는 길) · 코드 칸. 인증 앱을 찾지 못하면 키를 직접 넣으라고 알린다. - 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다). 문의 연락처 줄은 웹과 같은 자리. +- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다) + - 실패를 보이는 곳(웹 로그인 틀과 같은 결 — 화면이 칸 · 상자에 보이는 코드만 알림을 끈다): + + | 단계 | 칸 아래 | 위쪽 상자 | 그 밖 | + | --- | --- | --- | --- | + | 주소 · 비밀번호 | `VAL_001`(주소 칸) | `AUTH_004`(두 칸 빨강 · 비밀번호 비움) | 전역 알림(`REQ_001` 포함 — 웹과 같다) | + | 인증 앱 코드 | `AUTH_006` · `AUTH_007` · `VAL_001`(칸을 비움) | `REQ_001`(코드 그대로) | `AUTH_004` · 10분 지남은 첫 단계로(주소는 남긴다), 나머지는 전역 알림 | + | 새 비밀번호 | `AUTH_005`(임시 비밀번호) · `VAL_001`(새 비밀번호) · `AUTH_006` · `AUTH_007`(코드) | `REQ_001` · 넘기기 실패 + [다시 시도] | 전역 알림 | + | 2단계 켜기 | `AUTH_006` · `AUTH_007`(코드) · `AUTH_005`(지금 비밀번호) | `AUTH_008` + 새 키 · `REQ_001` | 서버의 `VAL_001` 과 나머지는 전역 알림 | + + - 칸 아래에는 안내와 오류를 함께 보인다(오류가 나도 "다음 코드가 뜨면 넣어 주세요" 같은 안내가 남는다). + - 세션이 끝나 로그인 화면으로 오면 위쪽 상자에 `AUTH_002` 문구만 보이고 알림(스낵바)은 띄우지 않는다(웹은 대화상자). + - [다른 계정으로 로그인]은 코드 단계에서는 늘 누를 수 있고(늦은 결과는 버린다), 새 비밀번호 · 2단계 켜기를 보내거나 넘기는 동안은 막는다(서버가 이미 바꿨을 수 있다 — 웹과 같다). + - 2단계 켜기는 처음 초점을 코드 칸에 두지 않는다 — 자판이 [인증 앱에 추가]를 가리지 않게(웹은 둔다). 켠 뒤 알림은 "2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다."이고 웹의 [앱 비밀번호 만들기] 단추는 뺐다(앱에 그 화면이 없다). + - 다른 곳에서 2단계를 켜면 이 세션은 서버가 끊는다(계약 6절) — 다음 요청에서 로그인 화면 + `AUTH_002`. 키를 받을 때 이미 켜져 있으면(`AUTH_008` 뒤 확인) 이 단계를 건너뛴다. + - 첫 단계 주소 칸만 자판 추천을 켠다(끄면 안드로이드 엔진이 비밀번호 보기 형식을 섞어 @ 자판이 나오지 않는다). 자동 고침은 모든 칸에서 끈다. + - 보드의 13.5 · 14.5 · 15.5 글자는 가까운 테마 역할(13 · 15 · 15)로 맞췄다. 한글은 낱말 단위로 줄을 바꾼다(`KeepAllText` — 보드의 `word-break: keep-all`). + - 새 글(보드에 없던 상태): 키를 복사하지 못하면 "키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.", ③ 안내 "지금 비밀번호를 한 번 더 넣어 주세요." — 그 밖은 웹 로그인 틀의 글을 그대로 옮겼다. + - **알려진 한계**: 인증 앱에 다녀오는 사이 안드로이드가 앱을 정리하면 돌아왔을 때 새 키가 나온다(인증 앱에 먼저 넣은 항목은 지우고 다시 넣어야 한다 — 옛 키의 코드는 `AUTH_006`, 웹의 새로고침과 같은 결). + 키 화면은 최근 앱 미리 보기에 찍히고(FLAG_SECURE 없음), 복사한 키는 클립보드에 남으며 안드로이드 13 이상은 복사 미리 보기에 보인다(Flutter 클립보드는 민감 표시를 못 한다 — 네이티브 채널이 든다). iOS 의 [인증 앱에 추가]는 iOS 를 붙일 때 여는 방식을 바꾼다. ### 메일 목록 (`App1MailList`) - 위 막대: 서랍 열기 · 메일함 이름 · 찾기. 서랍은 메일함만(안 읽은 수는 회색 글 — 뱃지 없음), 탭 막대 위까지 화면 전체를 덮는다. - 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 표시 / **미리 보기 한 줄**. 안 읽음은 굵게, 중요는 별. -- 2.52.0 From ad8b126abbfe3624acd984cc8f3d05b915469e92 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 19:36:31 +0900 Subject: [PATCH 04/32] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EC=95=B1=20=ED=8B=80=EC=9D=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EB=A7=88=EB=8B=A4=20=ED=83=AD=20=EB=A7=89=EB=8C=80?= =?UTF-8?q?=EB=A5=BC=20=EC=88=A8=EA=B8=B8=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= =?UTF-8?q?=20=ED=95=9C=EB=8B=A4=20(App1MailList)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 틀(core/router): - 틀을 Scaffold 가 아니라 Column + 탭 막대로 — 탭 안 화면마다 자기 Scaffold 를 두어 전역 알림이 [메일 쓰기] 위에 뜬다. 탭 막대는 위 안전 영역을 빼고(실기기에서 상태 표시줄만큼 두꺼워지지 않게), 화면이 ShellBarHidden 으로 숨길 수 있다(모듈마다 셈 · 보이는지는 TickerMode, 자판이 열리면 뺌) 메일 목록(features/mail): - 메일함 서랍(바꿔 끼움 · 안 읽은 수는 회색 글), 줄(사진 · 보낸 사람 · 시각 / 제목 · 첨부 / 미리 보기 한 줄 · 안 읽음 굵게 · 중요 별), 커서로 이어 받기 · 당겨서 새로고침 · 빈 메일함 · 30일 줄 - 고르기 막대(읽음 · 중요 · 이동 · 삭제) — 먼저 바꾸고 failedIds 만 되돌리기, 이동 · 삭제는 응답 id 만, 100통씩, 휴지통으로 옮기고 [되돌리기], 휴지통에서는 확인 뒤 영구 삭제, 붙잡힌 메일은 고를 수 없음 - 새 메일 확인: 보이고 앱이 앞에 있을 때만 1분마다, 다시 받은 쪽이 끝까지면 지운 옛 메일을 뺀다 - [되돌리기] 알림은 [메일 쓰기]와 한 자리(화면 낭독기를 켠 동안은 닫지 않음), 단추 없는 알림은 전역 스낵바 - 시험 346건(새로 119) 리뷰 반영: 탭 막대 높이, 고르는 중 알림 줄 위치, 이어 받기 중 새로고침 실패, 붙잡힌 메일 다시 보기, 메일함 역할을 알기 전 그리지 않음, 선택 풀기 · 되살리기를 계약에 맞춤, 맞추기 한 번에 하나. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/constants/app_constants.dart | 3 + flutter/lib/core/router/app_shell.dart | 128 +- flutter/lib/core/router/shell_bar.dart | 129 ++ flutter/lib/core/theme/app_colors.dart | 6 + flutter/lib/core/theme/app_spacing.dart | 41 + flutter/lib/core/theme/app_theme.dart | 51 + .../lib/core/utils/accessible_navigation.dart | 31 + flutter/lib/core/utils/app_foreground.dart | 24 + flutter/lib/core/utils/korean_format.dart | 46 + flutter/lib/core/utils/korean_text.dart | 40 +- flutter/lib/features/mail/data/.gitkeep | 0 .../mail/data/models/mail_address.dart | 46 + .../mail/data/models/mail_bulk_results.dart | 74 ++ .../features/mail/data/models/mail_page.dart | 89 ++ .../features/mail/data/models/mailbox.dart | 99 ++ .../mail/data/models/message_summary.dart | 198 +++ .../data/repositories/mail_repository.dart | 132 ++ flutter/lib/features/mail/domain/.gitkeep | 0 .../lib/features/mail/domain/mail_bulk.dart | 93 ++ .../features/mail/domain/mail_list_merge.dart | 56 + .../features/mail/domain/mailbox_tree.dart | 136 +++ flutter/lib/features/mail/mail_routes.dart | 28 +- .../mail/presentation/mail_row_view.dart | 196 +++ .../mail/presentation/mail_texts.dart | 184 ++- .../presentation/pages/mail_list_page.dart | 393 ++++++ .../providers/mail_list_controller.dart | 914 ++++++++++++++ .../providers/mail_list_state.dart | 140 +++ .../providers/mail_providers.dart | 22 + .../providers/mail_state_watcher.dart | 167 +++ .../providers/mail_toast_provider.dart | 145 +++ .../providers/mailboxes_provider.dart | 140 +++ .../widgets/mail_compose_area.dart | 195 +++ .../widgets/mail_list_app_bar.dart | 213 ++++ .../widgets/mail_list_states.dart | 366 ++++++ .../presentation/widgets/mail_move_sheet.dart | 83 ++ .../presentation/widgets/mailbox_drawer.dart | 321 +++++ .../presentation/widgets/mailbox_icons.dart | 13 + .../presentation/widgets/message_row.dart | 277 +++++ .../lib/shared/widgets/confirm_dialog.dart | 112 ++ flutter/test/app_test.dart | 23 +- flutter/test/core/router/app_shell_test.dart | 114 ++ flutter/test/core/router/shell_bar_test.dart | 30 + .../test/features/auth/login_pages_test.dart | 15 +- .../mail/mail_list_controller_test.dart | 1035 ++++++++++++++++ .../features/mail/mail_list_page_test.dart | 1085 +++++++++++++++++ .../features/mail/mail_repository_test.dart | 205 ++++ .../test/features/mail/mail_rules_test.dart | 521 ++++++++ flutter/test/support/mail_fakes.dart | 306 +++++ flutter/test/support/mail_harness.dart | 78 ++ 49 files changed, 8684 insertions(+), 59 deletions(-) create mode 100644 flutter/lib/core/router/shell_bar.dart create mode 100644 flutter/lib/core/utils/accessible_navigation.dart create mode 100644 flutter/lib/core/utils/app_foreground.dart create mode 100644 flutter/lib/core/utils/korean_format.dart delete mode 100644 flutter/lib/features/mail/data/.gitkeep create mode 100644 flutter/lib/features/mail/data/models/mail_address.dart create mode 100644 flutter/lib/features/mail/data/models/mail_bulk_results.dart create mode 100644 flutter/lib/features/mail/data/models/mail_page.dart create mode 100644 flutter/lib/features/mail/data/models/mailbox.dart create mode 100644 flutter/lib/features/mail/data/models/message_summary.dart create mode 100644 flutter/lib/features/mail/data/repositories/mail_repository.dart delete mode 100644 flutter/lib/features/mail/domain/.gitkeep create mode 100644 flutter/lib/features/mail/domain/mail_bulk.dart create mode 100644 flutter/lib/features/mail/domain/mail_list_merge.dart create mode 100644 flutter/lib/features/mail/domain/mailbox_tree.dart create mode 100644 flutter/lib/features/mail/presentation/mail_row_view.dart create mode 100644 flutter/lib/features/mail/presentation/pages/mail_list_page.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_list_controller.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_list_state.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_providers.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_list_states.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/message_row.dart create mode 100644 flutter/lib/shared/widgets/confirm_dialog.dart create mode 100644 flutter/test/core/router/app_shell_test.dart create mode 100644 flutter/test/core/router/shell_bar_test.dart create mode 100644 flutter/test/features/mail/mail_list_controller_test.dart create mode 100644 flutter/test/features/mail/mail_list_page_test.dart create mode 100644 flutter/test/features/mail/mail_repository_test.dart create mode 100644 flutter/test/features/mail/mail_rules_test.dart create mode 100644 flutter/test/support/mail_fakes.dart create mode 100644 flutter/test/support/mail_harness.dart diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index 7787fa6..382d0fa 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -7,6 +7,9 @@ library; /// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 const String kAppName = 'ARA SYSTEM'; +/// 아래 탭 막대(모듈 메뉴)의 화면 낭독기 이름 — 앱 디자인 README "앱 틀"(보드 `App1Shell` 의 `aria-label`) +const String kAppMenuLabel = '앱 메뉴'; + /// 회사 메일 도메인 — 화면 안내 글에만 쓴다(로그인 화면의 "회사 메일 주소(@…)"). /// 허용 도메인 검사는 서버가 한다. 웹 `frontend/src/shared/constants/brand.ts` 의 `COMPANY_MAIL_DOMAIN` 과 같은 값 const String kCompanyMailDomain = 'arasystem.it.kr'; diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart index 69a5e6d..028d85b 100644 --- a/flutter/lib/core/router/app_shell.dart +++ b/flutter/lib/core/router/app_shell.dart @@ -1,13 +1,21 @@ +import 'package:arasystem_app/core/constants/app_constants.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/shell_bar.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다. +/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다(앱 보드 `App1Shell`). /// -/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack). -/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다. -/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다. -class AppShell extends StatelessWidget { +/// - 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack). +/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다. +/// - **틀은 Scaffold 가 아니다** — 탭 안 화면마다 자기 Scaffold 를 둔다. 그래서 그 화면의 Scaffold 가 전역 알림(스낵바)을 받고, +/// 알림은 화면의 넓은 단추([메일 쓰기]) 위에 뜬다(틀이 Scaffold 면 알림이 단추를 가린다). +/// - 지금 탭의 모듈이 막대를 숨겼거나([ShellBarHidden] — 여러 통 고르기) 자판이 열려 있으면 막대를 뺀다. +/// 막대가 있을 때는 화면에서 아래 안전 영역을 뺀다(막대가 그 자리를 채운다). +class AppShell extends ConsumerWidget { const AppShell({ required this.navigationShell, required this.modules, @@ -22,27 +30,99 @@ class AppShell extends StatelessWidget { /// NavigationBar 는 탭이 둘 이상이어야 한다 static const int _minTabs = 2; + AppModuleId? get _currentModule { + final int index = navigationShell.currentIndex; + return index >= 0 && index < modules.length ? modules[index].id : null; + } + @override - Widget build(BuildContext context) { - return Scaffold( - body: navigationShell, - bottomNavigationBar: modules.length < _minTabs - ? null - : NavigationBar( - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: (int index) => navigationShell.goBranch( - index, - initialLocation: index == navigationShell.currentIndex, - ), - destinations: [ - for (final AppModule module in modules) - NavigationDestination( - icon: Icon(module.icon), - selectedIcon: Icon(module.selectedIcon), - label: module.label, - ), - ], + Widget build(BuildContext context, WidgetRef ref) { + final AppModuleId? current = _currentModule; + final bool hiddenByScreen = + current != null && + ref.watch( + shellBarHidingProvider.select( + (Map hiding) => isShellBarHidden(hiding, current), + ), + ); + final bool keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + final bool showBar = + modules.length >= _minTabs && !hiddenByScreen && !keyboardOpen; + + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + // 늘 같은 모양으로 감싼다 — 감싸는 위젯이 바뀌면 탭 안 화면(이동 기록)이 처음부터 다시 만들어진다 + Expanded( + child: MediaQuery.removePadding( + context: context, + removeBottom: showBar, + child: navigationShell, ), + ), + if (showBar) + // NavigationBar 는 안에서 네 방향 SafeArea 를 쓴다 — 화면 아래에 붙으니 위쪽(상태 표시줄) 여백은 뺀다 + // (Scaffold 의 bottomNavigationBar 자리와 같다) + MediaQuery.removePadding( + context: context, + removeTop: true, + child: _ModuleTabBar( + modules: modules, + selectedIndex: navigationShell.currentIndex, + onSelected: (int index) => navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ), + ), + ), + ], + ), + ); + } +} + +/// 아래 탭 막대 — 위에 옅은 선, 화면 낭독기 이름 "앱 메뉴" +class _ModuleTabBar extends StatelessWidget { + const _ModuleTabBar({ + required this.modules, + required this.selectedIndex, + required this.onSelected, + }); + + final List modules; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + return Semantics( + container: true, + label: kAppMenuLabel, + explicitChildNodes: true, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + destinations: [ + for (final AppModule module in modules) + NavigationDestination( + icon: Icon(module.icon), + selectedIcon: Icon(module.selectedIcon), + label: module.label, + ), + ], + ), + ), ); } } diff --git a/flutter/lib/core/router/shell_bar.dart b/flutter/lib/core/router/shell_bar.dart new file mode 100644 index 0000000..04bba5d --- /dev/null +++ b/flutter/lib/core/router/shell_bar.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 아래 탭 막대를 잠깐 숨긴 화면 수 — 모듈마다 센다(앱 디자인 README "앱 틀": 여러 통 고르기 중에는 탭 막대를 숨긴다). +/// +/// 틀(`app_shell.dart`)은 **지금 탭의 모듈**이 숨겼을 때만 막대를 빼므로, 다른 탭에서는 그대로 보인다. +/// 화면은 직접 부르지 않고 [ShellBarHidden] 으로 감싼다(위젯 생명주기 안에서는 프로바이더를 바꿀 수 없다). +final NotifierProvider> +shellBarHidingProvider = + NotifierProvider>(ShellBarHiding.new); + +/// 모듈 [module] 의 탭에서 막대를 숨겼는가 +bool isShellBarHidden(Map hiding, AppModuleId module) => + (hiding[module] ?? 0) > 0; + +/// 탭 막대를 숨긴 화면 수 +class ShellBarHiding extends Notifier> { + @override + Map build() => const {}; + + /// 한 화면이 숨긴다 + void hide(AppModuleId module) { + if (!ref.mounted) return; + state = {...state, module: (state[module] ?? 0) + 1}; + } + + /// 숨겼던 화면 하나가 돌려준다 + void reveal(AppModuleId module) { + if (!ref.mounted) return; + final int left = (state[module] ?? 0) - 1; + final Map next = {...state}; + if (left > 0) { + next[module] = left; + } else { + next.remove(module); + } + state = next; + } +} + +/// [hidden] 인 동안 [module] 의 탭에서 아래 탭 막대를 숨긴다 — 여러 통 고르기. +/// +/// 이 화면이 보일 때만 숨긴다 — 위에 다른 화면이 쌓였거나(받은편지함 위의 다른 메일함 · 전체 화면) 다른 탭이면 +/// 돌려준다(TickerMode). 고르던 받은편지함 위에 다른 메일함을 열어도 그 화면에는 탭 막대가 있다. +/// 프로바이더는 그리는 동안 바꿀 수 없어 그린 뒤에 알린다(한 프레임 늦다). 이 위젯이 사라지면 숨긴 것을 돌려준다. +class ShellBarHidden extends ConsumerStatefulWidget { + const ShellBarHidden({ + required this.module, + required this.hidden, + required this.child, + super.key, + }); + + final AppModuleId module; + final bool hidden; + final Widget child; + + @override + ConsumerState createState() => _ShellBarHiddenState(); +} + +class _ShellBarHiddenState extends ConsumerState { + /// 지금 숨겨 둔 모듈 — 숨기지 않았으면 null + AppModuleId? _hiddenModule; + ShellBarHiding? _hiding; + + /// 이 화면이 보이는가 — 가려졌으면 숨기지 않는다 + bool _visible = true; + + @override + void initState() { + super.initState(); + _scheduleSync(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final bool visible = TickerMode.valuesOf(context).enabled; + if (visible == _visible) return; + _visible = visible; + _scheduleSync(); + } + + @override + void didUpdateWidget(ShellBarHidden oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.hidden != widget.hidden || + oldWidget.module != widget.module) { + _scheduleSync(); + } + } + + void _scheduleSync() { + WidgetsBinding.instance.addPostFrameCallback((Duration _) => _sync()); + } + + void _sync() { + if (!mounted) return; + final AppModuleId? wanted = widget.hidden && _visible + ? widget.module + : null; + if (wanted == _hiddenModule) return; + final ShellBarHiding hiding = + _hiding ?? ref.read(shellBarHidingProvider.notifier); + _hiding = hiding; + final AppModuleId? previous = _hiddenModule; + if (previous != null) hiding.reveal(previous); + if (wanted != null) hiding.hide(wanted); + _hiddenModule = wanted; + } + + @override + void dispose() { + final AppModuleId? module = _hiddenModule; + final ShellBarHiding? hiding = _hiding; + // 위젯을 지우는 동안에는 프로바이더를 바꿀 수 없다 — 이 프레임이 끝난 뒤에 돌려준다 + if (module != null && hiding != null) { + scheduleMicrotask(() => hiding.reveal(module)); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index eab1dff..a6d49be 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -24,6 +24,12 @@ abstract final class AppColors { /// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 static const Color brandTint = Color(0xFFF3F8F8); + /// `--color-brand-tint-border` — 도구 줄(바탕 [brandTint])의 아래 선 + static const Color brandTintBorder = Color(0xFFE3EEEF); + + /// `--color-row-checked` — 고른 행 (여러 통 처리) + static const Color rowChecked = Color(0xFFE8F4F5); + /// `--color-on-brand` static const Color onBrand = Color(0xFFFFFFFF); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index beade93..0bd4d15 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -44,6 +44,10 @@ abstract final class AppRadius { /// `--radius-xl` — 대화상자 static const double xl = 12; + + // ── 앱 보드에서 정한 모서리 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ── + /// [메일 쓰기] 단추 · 메일함 서랍의 오른쪽 모서리 · 확인 창 · 아래에서 올라오는 시트 + static const double xxl = 16; } /// 크기 @@ -79,4 +83,41 @@ abstract final class AppSize { /// 넓은 화면(태블릿 · 가로)에서 양식 열의 최대 폭 — 휴대폰(390)에서는 화면 폭을 다 쓴다 static const double formMaxWidth = 480; + + // ── 앱 보드에서 정한 크기 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ── + /// 위 막대 높이 — 목록의 머리 · 여러 통 고르기 막대 + static const double appBarHeight = 64; + + /// 아래 탭 막대 높이 + static const double navigationBarHeight = 72; + + /// 큰 아이콘 — 위 막대의 서랍 열기 · 찾기 · 선택 해제 + static const double iconLarge = 24; + + /// 도구 아이콘 — 고르기 막대 · 아래 탭 막대 · [메일 쓰기] + static const double iconToolbar = 22; + + /// 작은 표시 아이콘 — 목록 줄의 첨부 · 중요 + static const double iconTiny = 16; + + /// 목록 줄의 동그란 사진(머리글자) + static const double avatar = 40; + + /// 메일함 서랍의 폭 — 좁은 휴대폰에서는 화면 폭에서 [drawerMinGap] 을 남긴다 + static const double drawerWidth = 304; + + /// 메일함 서랍 오른쪽에 남기는 가림막 폭 (Material 서랍 규칙) + static const double drawerMinGap = 56; + + /// 빈 메일함 · 받지 못함의 아이콘 원 + static const double stateIcon = 72; + + /// 빈 메일함 · 받지 못함 원 안의 아이콘 + static const double stateIconGlyph = 30; + + /// 알림 줄(되돌리기)의 최소 높이 + static const double toastMinHeight = 52; + + /// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다 + static const double fabHeight = 56; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 2bca6ef..8e6a075 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -97,10 +97,45 @@ abstract final class AppTheme { borderRadius: BorderRadius.circular(AppRadius.lg), ), ), + navigationBarTheme: _navigationBarTheme(scheme, textTheme), extensions: const [AppExtraColors.light], ); } + /// 아래 탭 막대 — 앱 보드 `App1Shell` · `App1MailList`(높이 72 · 고른 탭은 청록 바탕 알약 안의 아이콘 · 이름 12.5 → 테마 12). + /// 알약 크기는 Material 3 그대로(64×32 — 보드는 56×32)다. 위 선은 틀(`app_shell.dart`)이 그린다 + static NavigationBarThemeData _navigationBarTheme( + ColorScheme scheme, + TextTheme textTheme, + ) { + return NavigationBarThemeData( + height: AppSize.navigationBarHeight, + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + indicatorColor: scheme.primaryContainer, + indicatorShape: const StadiumBorder(), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + labelTextStyle: WidgetStateProperty.resolveWith(( + Set states, + ) { + final bool selected = states.contains(WidgetState.selected); + return textTheme.labelSmall?.copyWith( + fontWeight: selected ? AppTypography.bold : AppTypography.medium, + color: selected ? scheme.onPrimaryContainer : scheme.onSurfaceVariant, + ); + }), + iconTheme: WidgetStateProperty.resolveWith( + (Set states) => IconThemeData( + size: AppSize.iconToolbar, + color: states.contains(WidgetState.selected) + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant, + ), + ), + ); + } + static OutlineInputBorder _inputBorder( Color color, { double width = AppSize.borderWidth, @@ -169,6 +204,8 @@ class AppExtraColors extends ThemeExtension { required this.warningBorder, required this.successText, required this.star, + required this.rowChecked, + required this.brandTintBorder, }); static const AppExtraColors light = AppExtraColors( @@ -184,6 +221,8 @@ class AppExtraColors extends ThemeExtension { warningBorder: AppColors.warningBorder, successText: AppColors.successText, star: AppColors.star, + rowChecked: AppColors.rowChecked, + brandTintBorder: AppColors.brandTintBorder, ); /// 시각 · 개수 · 보조 설명 @@ -214,6 +253,12 @@ class AppExtraColors extends ThemeExtension { /// 중요 표시 final Color star; + /// 고른 행 (여러 통 처리) + final Color rowChecked; + + /// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선 + final Color brandTintBorder; + @override AppExtraColors copyWith({ Color? textMuted, @@ -228,6 +273,8 @@ class AppExtraColors extends ThemeExtension { Color? warningBorder, Color? successText, Color? star, + Color? rowChecked, + Color? brandTintBorder, }) { return AppExtraColors( textMuted: textMuted ?? this.textMuted, @@ -242,6 +289,8 @@ class AppExtraColors extends ThemeExtension { warningBorder: warningBorder ?? this.warningBorder, successText: successText ?? this.successText, star: star ?? this.star, + rowChecked: rowChecked ?? this.rowChecked, + brandTintBorder: brandTintBorder ?? this.brandTintBorder, ); } @@ -261,6 +310,8 @@ class AppExtraColors extends ThemeExtension { warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!, successText: Color.lerp(successText, other.successText, t)!, star: Color.lerp(star, other.star, t)!, + rowChecked: Color.lerp(rowChecked, other.rowChecked, t)!, + brandTintBorder: Color.lerp(brandTintBorder, other.brandTintBorder, t)!, ); } } diff --git a/flutter/lib/core/utils/accessible_navigation.dart b/flutter/lib/core/utils/accessible_navigation.dart new file mode 100644 index 0000000..2689a09 --- /dev/null +++ b/flutter/lib/core/utils/accessible_navigation.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 화면 낭독기(TalkBack 등)로 화면을 다루는 중인가 — 기기의 접근성 설정 `accessibleNavigation`. +/// +/// 단추가 있는 알림([되돌리기])은 이 동안 저절로 닫지 않는다 — Flutter SnackBar · 웹 알림의 붙잡기(hold)와 같은 결이다. +/// 화면 밖(노티파이어)에서 읽으려고 둔다. 시험은 `tester.platformDispatcher.accessibilityFeaturesTestValue` 로 바꾸거나 이것을 덮어쓴다. +final NotifierProvider +accessibleNavigationProvider = NotifierProvider( + AccessibleNavigation.new, +); + +/// 기기의 접근성 설정을 듣는다 +class AccessibleNavigation extends Notifier with WidgetsBindingObserver { + @override + bool build() { + final WidgetsBinding binding = WidgetsBinding.instance; + binding.addObserver(this); + ref.onDispose(() => binding.removeObserver(this)); + return _read(binding); + } + + @override + void didChangeAccessibilityFeatures() { + if (!ref.mounted) return; + state = _read(WidgetsBinding.instance); + } + + static bool _read(WidgetsBinding binding) => + binding.platformDispatcher.accessibilityFeatures.accessibleNavigation; +} diff --git a/flutter/lib/core/utils/app_foreground.dart b/flutter/lib/core/utils/app_foreground.dart new file mode 100644 index 0000000..ab67473 --- /dev/null +++ b/flutter/lib/core/utils/app_foreground.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 앞에 보이는가 — 안드로이드의 앱 생명주기가 `resumed` 일 때만 참이다. +/// +/// 화면이 보이는 동안만 하는 일(새 메일 1분 확인 — 웹의 `visibilityState === 'visible'` 자리)이 듣는다. +/// 시험은 `tester.binding.handleAppLifecycleStateChanged` 로 바꾸거나 이 프로바이더를 덮어쓴다. +final NotifierProvider appForegroundProvider = + NotifierProvider(AppForeground.new); + +/// 앱 생명주기를 듣는다 +class AppForeground extends Notifier { + @override + bool build() { + final AppLifecycleListener listener = AppLifecycleListener( + onStateChange: (AppLifecycleState lifecycle) => + state = lifecycle == AppLifecycleState.resumed, + ); + ref.onDispose(listener.dispose); + final AppLifecycleState? current = WidgetsBinding.instance.lifecycleState; + // 아직 생명주기를 받기 전(앱을 막 띄움 · 시험)은 앞에 보이는 것으로 본다 + return current == null || current == AppLifecycleState.resumed; + } +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart new file mode 100644 index 0000000..1152fba --- /dev/null +++ b/flutter/lib/core/utils/korean_format.dart @@ -0,0 +1,46 @@ +/// 화면에 보이는 숫자 · 시각 표기 — 웹 `shared/utils/format.ts` 의 ko-KR 표기와 같은 글을 손으로 만든다(순수 함수). +/// +/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 — +/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다. +library; + +/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다) +String formatCount(int value) { + final String digits = value.abs().toString(); + final StringBuffer out = StringBuffer(value < 0 ? '-' : ''); + for (int index = 0; index < digits.length; index++) { + if (index > 0 && (digits.length - index) % _digitGroup == 0) { + out.write(','); + } + out.write(digits[index]); + } + return out.toString(); +} + +const int _digitGroup = 3; +const int _hoursHalfDay = 12; + +/// 하루 안의 시각 — "오전 10:42" · "오후 3:05" · "오전 12:30" (웹 `{ hour: 'numeric', minute: '2-digit' }` 와 같다) +String koreanTimeOfDay(DateTime local) { + final String half = local.hour < _hoursHalfDay ? '오전' : '오후'; + final int hour = local.hour % _hoursHalfDay == 0 + ? _hoursHalfDay + : local.hour % _hoursHalfDay; + final String minute = local.minute.toString().padLeft(2, '0'); + return '$half $hour:$minute'; +} + +/// 달 · 날 — "11월 3일" (웹 `{ month: 'long', day: 'numeric' }`) +String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일'; + +/// 연도까지 숫자로 — "2025. 10. 24." (웹 `{ year · month · day: 'numeric' }`) +String koreanNumericDate(DateTime local) => + '${local.year}. ${local.month}. ${local.day}.'; + +/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값) +bool isSameCalendarDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +/// 하루 전 달력 날 — 달 · 해의 경계를 넘는다(1월 1일 → 12월 31일) +DateTime previousCalendarDay(DateTime local) => + DateTime(local.year, local.month, local.day - 1); diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 8c56773..072f886 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -1,4 +1,5 @@ -/// 한국어 글의 줄 나눔 — 웹 `word-break: keep-all` 에 해당한다(앱 보드 `App1Login` 의 안내 글). +/// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과 +/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다). library; /// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) @@ -26,7 +27,42 @@ String keepAllWordBreak(String text) { return out.toString(); } -bool _isHangulSyllable(int rune) => rune >= 0xAC00 && rune <= 0xD7A3; +bool _isHangulSyllable(int rune) => rune >= _hangulFirst && rune <= _hangulLast; + +const int _hangulFirst = 0xAC00; +const int _hangulLast = 0xD7A3; + +/// 한글 음절 하나의 받침 경우의 수(받침 없음 포함) +const int _jongseongCount = 28; +const int _noJongseong = 0; + +/// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로) +const int _jongseongRieul = 8; + +/// 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글) null +int? _lastJongseong(String word) { + final String trimmed = word.trim(); + if (trimmed.isEmpty) return null; + final int last = trimmed.runes.last; + if (!_isHangulSyllable(last)) return null; + return (last - _hangulFirst) % _jongseongCount; +} + +/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가). +/// 한글로 끝나지 않는 이름(영문 · 숫자)은 소리를 알 수 없어 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). +String subjectParticle(String word) { + final int? jongseong = _lastJongseong(word); + if (jongseong == null) return '이(가)'; + return jongseong == _noJongseong ? '가' : '이'; +} + +/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로). +/// 한글로 끝나지 않는 이름은 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). +String directionParticle(String word) { + final int? jongseong = _lastJongseong(word); + if (jongseong == null) return '(으)로'; + return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로'; +} /// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; diff --git a/flutter/lib/features/mail/data/.gitkeep b/flutter/lib/features/mail/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/data/models/mail_address.dart b/flutter/lib/features/mail/data/models/mail_address.dart new file mode 100644 index 0000000..3999fc7 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_address.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +/// 메일 주소 하나 — 계약 2절 `Address { name: string | null; email: string }`. +/// +/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null 이다 — 저장소가 `SYS_001` 로 던진다. +@immutable +class MailAddress { + const MailAddress({required this.email, this.name}); + + /// 보이는 이름 — 없으면 null + final String? name; + final String email; + + /// 목록에 보이는 이름 — 이름이 없거나 비었으면 주소 (웹 `displayName`) + String get displayName { + final String? trimmed = name?.trim(); + return trimmed == null || trimmed.isEmpty ? email : trimmed; + } + + static MailAddress? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + final Object? name = json['name']; + if (email is! String || (name != null && name is! String)) return null; + return MailAddress(email: email, name: name as String?); + } + + /// 주소 배열 — 하나라도 모양이 틀리면 null + static List? tryParseList(Object? json) { + if (json is! List) return null; + final List addresses = []; + for (final Object? item in json) { + final MailAddress? address = tryParse(item); + if (address == null) return null; + addresses.add(address); + } + return List.unmodifiable(addresses); + } + + @override + bool operator ==(Object other) => + other is MailAddress && other.email == email && other.name == name; + + @override + int get hashCode => Object.hash(email, name); +} diff --git a/flutter/lib/features/mail/data/models/mail_bulk_results.dart b/flutter/lib/features/mail/data/models/mail_bulk_results.dart new file mode 100644 index 0000000..10eb26f --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_bulk_results.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; + +/// 메일 한 통 · 여러 통에 바꿀 것 — `PATCH /api/messages{/id}` 의 본문(계약 2절). 하나 이상 넣는다 +@immutable +class MessageChanges { + const MessageChanges({ + this.isRead, + this.isFlagged, + this.mailboxId, + this.isJunk, + }); + + final bool? isRead; + final bool? isFlagged; + + /// 이동 — 그 메일함 하나에만 속하게 + final String? mailboxId; + + /// 스팸 표시 (4단계 "스팸 · 원문") + final bool? isJunk; + + /// 보낼 본문 — 정한 칸만 싣는다 + Map toJson() => { + 'isRead': ?isRead, + 'isFlagged': ?isFlagged, + 'mailboxId': ?mailboxId, + 'isJunk': ?isJunk, + }; +} + +/// `PATCH /api/messages` 의 결과 — 계약 2절 `MessagesPatched` +@immutable +class MessagesPatched { + const MessagesPatched({required this.updatedIds, required this.failedIds}); + + final List updatedIds; + + /// 없음 · 그사이 지워짐 · 엔진이 거절함 · 붙잡힌 메일의 이동 — 나머지는 그대로 처리했다 + final List failedIds; + + static MessagesPatched? tryParse(Object? json) { + if (json is! Map) return null; + final List? updated = _idsOf(json['updatedIds']); + final List? failed = _idsOf(json['failedIds']); + if (updated == null || failed == null) return null; + return MessagesPatched(updatedIds: updated, failedIds: failed); + } +} + +/// `POST /api/message-deletions` 의 결과 — 계약 2절 `MessageDeletionResult` +@immutable +class MessageDeletions { + const MessageDeletions({required this.deletedIds, required this.skippedIds}); + + /// 영구 삭제했다 · 휴지통에서만 뺐다 + final List deletedIds; + + /// 휴지통에 없음 · 없음 · 엔진이 거절 · 붙잡힌 메일 — 지우지 않았다 + final List skippedIds; + + static MessageDeletions? tryParse(Object? json) { + if (json is! Map) return null; + final List? deleted = _idsOf(json['deletedIds']); + final List? skipped = _idsOf(json['skippedIds']); + if (deleted == null || skipped == null) return null; + return MessageDeletions(deletedIds: deleted, skippedIds: skipped); + } +} + +List? _idsOf(Object? value) { + if (value is! List) return null; + if (value.any((Object? id) => id is! String)) return null; + return List.unmodifiable(value.cast()); +} diff --git a/flutter/lib/features/mail/data/models/mail_page.dart b/flutter/lib/features/mail/data/models/mail_page.dart new file mode 100644 index 0000000..7709985 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_page.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; + +/// 목록 응답의 `pageInfo` — 응답 포맷 규칙 "목록 응답 (페이지네이션)" +@immutable +class MailPageInfo { + const MailPageInfo({required this.hasNext, this.nextCursor, this.total}); + + final bool hasNext; + + /// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다(계약 2절). 마지막 쪽이면 null + final String? nextCursor; + + /// 전체 수 — 계산 비용이 크면 null + final int? total; + + /// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만 + bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false); + + MailPageInfo copyWith({int? total}) => MailPageInfo( + hasNext: hasNext, + nextCursor: nextCursor, + total: total ?? this.total, + ); + + static MailPageInfo? tryParse(Object? json) { + if (json is! Map) return null; + final Object? hasNext = json['hasNext']; + final Object? nextCursor = json['nextCursor']; + final Object? total = json['total']; + if (hasNext is! bool || + (nextCursor != null && nextCursor is! String) || + (total != null && total is! num)) { + return null; + } + return MailPageInfo( + hasNext: hasNext, + nextCursor: nextCursor as String?, + total: (total as num?)?.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is MailPageInfo && + other.hasNext == hasNext && + other.nextCursor == nextCursor && + other.total == total; + + @override + int get hashCode => Object.hash(hasNext, nextCursor, total); +} + +/// 목록 한 쪽 — `{ items, pageInfo }` +@immutable +class MailPage { + const MailPage({ + required this.items, + required this.pageInfo, + this.serverTime, + }); + + final List items; + final MailPageInfo pageInfo; + + /// 응답의 `Date` 머리(서버 시각) — 없으면 null. 붙잡아 둔 발송이 풀릴 때를 서버 시계로 재는 데 쓴다 + final DateTime? serverTime; + + /// 서버 시각을 붙인 쪽 + MailPage withServerTime(DateTime? serverTime) => + MailPage(items: items, pageInfo: pageInfo, serverTime: serverTime); + + /// 모양이 틀리면(항목 하나라도) null + static MailPage? tryParse( + Object? json, + T? Function(Object? item) parseItem, + ) { + if (json is! Map) return null; + final Object? rawItems = json['items']; + final MailPageInfo? pageInfo = MailPageInfo.tryParse(json['pageInfo']); + if (rawItems is! List || pageInfo == null) return null; + final List items = []; + for (final Object? raw in rawItems) { + final T? item = parseItem(raw); + if (item == null) return null; + items.add(item); + } + return MailPage(items: List.unmodifiable(items), pageInfo: pageInfo); + } +} diff --git a/flutter/lib/features/mail/data/models/mailbox.dart b/flutter/lib/features/mail/data/models/mailbox.dart new file mode 100644 index 0000000..7cfa6a4 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mailbox.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; + +/// 메일함 역할 — 계약 2절 `Mailbox.role` (역할이 있으면 서버가 한국어 표준 이름을 준다) +enum MailboxRole { + inbox, + drafts, + sent, + junk, + trash, + archive; + + /// 응답 칸을 읽는다 — null · 모르는 이름은 역할 없음(사용자가 만든 메일함)으로 본다 + static MailboxRole? parse(Object? value) { + for (final MailboxRole role in MailboxRole.values) { + if (role.name == value) return role; + } + return null; + } +} + +/// 메일함 하나 — 계약 2절 `Mailbox` +@immutable +class Mailbox { + const Mailbox({ + required this.id, + required this.name, + required this.role, + required this.parentId, + required this.sortOrder, + required this.totalCount, + required this.unreadCount, + }); + + final String id; + + /// 역할이 있으면 한국어 표준 이름(받은편지함 등), 없으면 서버의 이름 + final String name; + + /// 역할 — null 이면 사용자가 만든 메일함("내 메일함") + final MailboxRole? role; + final String? parentId; + final int sortOrder; + final int totalCount; + final int unreadCount; + + Mailbox copyWith({int? unreadCount}) => Mailbox( + id: id, + name: name, + role: role, + parentId: parentId, + sortOrder: sortOrder, + totalCount: totalCount, + unreadCount: unreadCount ?? this.unreadCount, + ); + + /// 모양이 틀리면 null + static Mailbox? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? parentId = json['parentId']; + final Object? sortOrder = json['sortOrder']; + final Object? totalCount = json['totalCount']; + final Object? unreadCount = json['unreadCount']; + if (id is! String || + id.isEmpty || + name is! String || + (parentId != null && parentId is! String) || + sortOrder is! num || + totalCount is! num || + unreadCount is! num) { + return null; + } + return Mailbox( + id: id, + name: name, + role: MailboxRole.parse(json['role']), + parentId: parentId as String?, + sortOrder: sortOrder.toInt(), + totalCount: totalCount.toInt(), + unreadCount: unreadCount.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is Mailbox && + other.id == id && + other.name == name && + other.role == role && + other.parentId == parentId && + other.sortOrder == sortOrder && + other.totalCount == totalCount && + other.unreadCount == unreadCount; + + @override + int get hashCode => + Object.hash(id, name, role, parentId, sortOrder, totalCount, unreadCount); +} diff --git a/flutter/lib/features/mail/data/models/message_summary.dart b/flutter/lib/features/mail/data/models/message_summary.dart new file mode 100644 index 0000000..654e5f1 --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_summary.dart @@ -0,0 +1,198 @@ +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 붙잡아 둔 내 발송의 종류 — 계약 2절 "보내기 취소 · 예약 발송"(9단계) +enum PendingSendKind { + /// [보내기]를 누른 뒤 보내기 취소를 기다리는 중 + undo, + + /// 예약 발송 + scheduled, +} + +/// 엔진이 붙잡아 둔 내 발송 — 아직 보내지 않았다(보내는 중 · 예약) +@immutable +class PendingSend { + const PendingSend({required this.sendAt, required this.kind}); + + /// 엔진이 보낼 시각 (UTC) + final DateTime sendAt; + final PendingSendKind kind; + + /// 응답의 값을 읽는다 — 칸이 없거나(9단계 앞 서버) 모양이 틀리면 없는 것(null)으로 본다(웹 `pendingSendOf` 와 같다) + static PendingSend? tryParse(Object? json) { + if (json is! Map) return null; + final Object? sendAt = json['sendAt']; + final Object? kind = json['kind']; + final DateTime? at = sendAt is String ? DateTime.tryParse(sendAt) : null; + final PendingSendKind? parsedKind = switch (kind) { + 'undo' => PendingSendKind.undo, + 'scheduled' => PendingSendKind.scheduled, + _ => null, + }; + if (at == null || parsedKind == null) return null; + return PendingSend(sendAt: at.toUtc(), kind: parsedKind); + } + + @override + bool operator ==(Object other) => + other is PendingSend && other.sendAt == sendAt && other.kind == kind; + + @override + int get hashCode => Object.hash(sendAt, kind); +} + +/// 목록의 메일 한 통 — 계약 2절 `MessageSummary` +@immutable +class MessageSummary { + const MessageSummary({ + required this.id, + required this.threadId, + required this.mailboxIds, + required this.from, + required this.to, + required this.subject, + required this.preview, + required this.receivedAt, + required this.isRead, + required this.isFlagged, + required this.hasAttachment, + required this.size, + this.pendingSend, + }); + + final String id; + final String threadId; + final List mailboxIds; + final MailAddress? from; + final List to; + + /// 제목이 없으면 빈 글 + final String subject; + final String preview; + + /// 받은 시각 (UTC) + final DateTime receivedAt; + final bool isRead; + final bool isFlagged; + final bool hasAttachment; + + /// 바이트 + final int size; + + /// 아직 보내지 않은 내 메일이면 그 정보 — 그 밖에는 null + final PendingSend? pendingSend; + + /// 아직 보내지 않은 메일(보내는 중 · 예약)인가 — 취소해야만 보낸편지함을 떠난다. + /// 옮기거나 지워도 엔진은 그 시각에 보내므로 목록에서 고를 수 없다(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다") + bool get isHeld => pendingSend != null; + + MessageSummary copyWith({ + bool? isRead, + bool? isFlagged, + List? mailboxIds, + }) => MessageSummary( + id: id, + threadId: threadId, + mailboxIds: mailboxIds ?? this.mailboxIds, + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: receivedAt, + isRead: isRead ?? this.isRead, + isFlagged: isFlagged ?? this.isFlagged, + hasAttachment: hasAttachment, + size: size, + pendingSend: pendingSend, + ); + + /// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다 + static MessageSummary? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? threadId = json['threadId']; + final Object? mailboxIds = json['mailboxIds']; + final Object? fromJson = json['from']; + final Object? subject = json['subject']; + final Object? preview = json['preview']; + final Object? receivedAt = json['receivedAt']; + final Object? isRead = json['isRead']; + final Object? isFlagged = json['isFlagged']; + final Object? hasAttachment = json['hasAttachment']; + final Object? size = json['size']; + + final MailAddress? from = fromJson == null + ? null + : MailAddress.tryParse(fromJson); + final List? to = MailAddress.tryParseList(json['to']); + final DateTime? received = receivedAt is String + ? DateTime.tryParse(receivedAt) + : null; + if (id is! String || + id.isEmpty || + threadId is! String || + mailboxIds is! List || + mailboxIds.any((Object? value) => value is! String) || + (fromJson != null && from == null) || + to == null || + subject is! String || + preview is! String || + received == null || + isRead is! bool || + isFlagged is! bool || + hasAttachment is! bool || + size is! num) { + return null; + } + return MessageSummary( + id: id, + threadId: threadId, + mailboxIds: List.unmodifiable(mailboxIds.cast()), + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: received.toUtc(), + isRead: isRead, + isFlagged: isFlagged, + hasAttachment: hasAttachment, + size: size.toInt(), + pendingSend: PendingSend.tryParse(json['pendingSend']), + ); + } + + @override + bool operator ==(Object other) => + other is MessageSummary && + other.id == id && + other.threadId == threadId && + listEquals(other.mailboxIds, mailboxIds) && + other.from == from && + listEquals(other.to, to) && + other.subject == subject && + other.preview == preview && + other.receivedAt == receivedAt && + other.isRead == isRead && + other.isFlagged == isFlagged && + other.hasAttachment == hasAttachment && + other.size == size && + other.pendingSend == pendingSend; + + @override + int get hashCode => Object.hash( + id, + threadId, + Object.hashAll(mailboxIds), + from, + Object.hashAll(to), + subject, + preview, + receivedAt, + isRead, + isFlagged, + hasAttachment, + size, + pendingSend, + ); +} diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart new file mode 100644 index 0000000..d01397e --- /dev/null +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -0,0 +1,132 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/server_time.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:dio/dio.dart'; + +/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다 +abstract final class MailApiPaths { + static const String mailboxes = '/mailboxes'; + static const String messages = '/messages'; + + /// 메일 한 통 — id 는 경로 칸으로 인코딩한다 + static String message(String id) => '$messages/${Uri.encodeComponent(id)}'; + + /// 휴지통에서 영구 삭제 (여러 통) + static const String messageDeletions = '/message-deletions'; + + /// 새 메일 확인 — 불투명한 상태 글 + static const String mailState = '/mail-state'; +} + +/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다 +const int kMailPageSize = 20; + +/// 여러 통 요청 하나의 id 한도 — 넘으면 이만큼씩 차례로 보낸다(계약 2절 "여러 통 한꺼번에") +const int kMailBulkMaxIds = 100; + +/// 메일 API — 계약 `docs/mail/api-contract.md` 2절. 웹과 같은 경로를 Bearer 로 부른다. +/// +/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. +class MailRepository { + const MailRepository(this._dio); + + final Dio _dio; + + /// 메일함 목록 — 알림은 띄우지 않는다(목록 · 서랍이 받지 못함을 직접 그린다) + Future> listMailboxes() async { + final Response response = await _dio.get( + MailApiPaths.mailboxes, + options: ApiOptions.silent(), + ); + final MailPage? page = MailPage.tryParse( + response.data, + Mailbox.tryParse, + ); + if (page == null) throw _malformed(response); + return page.items; + } + + /// 메일함 하나의 목록 — 받은 시각 내림차순. [cursor] 는 해석하지 않고 그대로 보낸다. + /// [silent] 면 실패를 알리지 않는다(처음 여는 목록은 화면이 받지 못함을 직접 그린다 · 새 메일 맞추기) + Future> listMessages({ + required String mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + 'mailboxId': mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MessageSummary.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다) + Future updateMessage( + String id, + MessageChanges changes, + ) async { + final Response response = await _dio.patch( + MailApiPaths.message(id), + data: changes.toJson(), + ); + final MessageSummary? summary = MessageSummary.tryParse(response.data); + if (summary == null) throw _malformed(response); + return summary; + } + + /// 여러 통 바꾸기 — [ids] 는 100개까지(나눠 보내는 일은 부르는 쪽이 한다) + Future updateMessages( + List ids, + MessageChanges changes, + ) async { + final Response response = await _dio.patch( + MailApiPaths.messages, + data: {'ids': ids, ...changes.toJson()}, + ); + final MessagesPatched? result = MessagesPatched.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 휴지통에서 영구 삭제 — 확인을 받은 뒤에만 부른다. [ids] 는 100개까지 + Future deleteMessages(List ids) async { + final Response response = await _dio.post( + MailApiPaths.messageDeletions, + data: {'ids': ids}, + ); + final MessageDeletions? result = MessageDeletions.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 새 메일 확인 — 상태 글(해석하지 않고 지난 값과 같은지만 본다). 실패는 알리지 않는다(다음 차례에 다시 묻는다) + Future mailState() async { + final Response response = await _dio.get( + MailApiPaths.mailState, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + final Object? state = data is Map ? data['state'] : null; + if (state is! String) throw _malformed(response); + return state; + } + + DioException _malformed(Response response) => + apiFailure(response.requestOptions, kFallbackCode); +} diff --git a/flutter/lib/features/mail/domain/.gitkeep b/flutter/lib/features/mail/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/domain/mail_bulk.dart b/flutter/lib/features/mail/domain/mail_bulk.dart new file mode 100644 index 0000000..af824cd --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_bulk.dart @@ -0,0 +1,93 @@ +/// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 (웹 `shared/utils/mail-bulk.ts` 와 같은 규칙, 순수 함수 · 문구 없음) +library; + +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// [size] 개씩 나눈다 — 요청 하나의 ids 는 100개까지다 +List> chunked(List items, int size) { + final int step = math.max(1, size); + return >[ + for (int start = 0; start < items.length; start += step) + items.sublist(start, math.min(start + step, items.length)), + ]; +} + +/// 여러 통 처리의 결과 +@immutable +class BulkResult { + const BulkResult({ + this.doneIds = const [], + this.skippedIds = const [], + this.unsentIds = const [], + }); + + /// 처리한 메일 + final List doneIds; + + /// 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds + final List skippedIds; + + /// 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 + final List unsentIds; + + /// 그대로 둔 메일 · 보내지 못한 메일이 있다 — 목록을 서버 기준으로 다시 맞춘다 + bool get hasLeftovers => skippedIds.isNotEmpty || unsentIds.isNotEmpty; +} + +/// 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 +typedef BulkChunkSender = + Future<({List done, List skipped})> Function( + List chunkIds, + ); + +/// [ids] 를 [size] 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다 +/// (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다) +Future sendInChunks( + List ids, + int size, + BulkChunkSender send, +) async { + final List done = []; + final List skipped = []; + final List unsent = []; + final List> chunks = chunked(ids, size); + for (int index = 0; index < chunks.length; index++) { + try { + final ({List done, List skipped}) result = await send( + chunks[index], + ); + done.addAll(result.done); + skipped.addAll(result.skipped); + } on DioException { + unsent.addAll(chunks.sublist(index).expand((List c) => c)); + break; + } + } + return BulkResult(doneIds: done, skippedIds: skipped, unsentIds: unsent); +} + +/// 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일 +@immutable +class BulkOrigin { + const BulkOrigin(this.mailboxId, this.ids); + + final String mailboxId; + final List ids; +} + +/// 옮긴 메일을 원래 메일함별로 묶는다(옮긴 차례대로). 원래 메일함을 모르는 메일은 뺀다 +List groupByOrigin(List ids, Map originOf) { + final Map> groups = >{}; + for (final String id in ids) { + final String? origin = originOf[id]; + if (origin == null) continue; + (groups[origin] ??= []).add(id); + } + return [ + for (final MapEntry> entry in groups.entries) + BulkOrigin(entry.key, List.unmodifiable(entry.value)), + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_list_merge.dart b/flutter/lib/features/mail/domain/mail_list_merge.dart new file mode 100644 index 0000000..8274d50 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_list_merge.dart @@ -0,0 +1,56 @@ +/// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 — 순수 함수 (웹 `shared/utils/mail-list-merge.ts` 와 같다). +/// +/// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다(새 메일 · 다른 창의 읽음 · 이동 · 삭제). +/// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일 +/// (밀려난 메일을 버리면 보던 줄이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다. +/// - 다시 받은 쪽이 메일함의 끝까지였으면(`hasNext` 거짓) 범위 밖이 없다 — 받지 못한 옛 메일은 다른 곳에서 지워진 것이라 남기지 않는다 +/// (웹은 이때도 남긴다 — 앱에서 정함 2026-11-06). +library; + +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; + +/// 병합 결과 +typedef MailListMerge = ({List items, MailPageInfo pageInfo}); + +/// 지금 목록([current] · [currentPageInfo])과 다시 받은 첫 범위([fetched] · [fetchedPageInfo])를 합친다 +MailListMerge mergeRefreshedList({ + required List current, + required MailPageInfo? currentPageInfo, + required List fetched, + required MailPageInfo fetchedPageInfo, +}) { + final Set fetchedIds = { + for (final MessageSummary message in fetched) message.id, + }; + + // 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다 + int lastOverlap = -1; + for (int index = 0; index < current.length; index++) { + if (fetchedIds.contains(current[index].id)) lastOverlap = index; + } + final List tail = + lastOverlap == -1 || !fetchedPageInfo.hasNext + ? [] + : current + .sublist(lastOverlap + 1) + .where((MessageSummary message) => !fetchedIds.contains(message.id)) + .toList(); + + // 남겨 둔 옛 메일이 있으면 다음 쪽은 그 뒤부터 이어야 한다 — 옛 커서를 쓰고 전체 수는 새 값 + final MailPageInfo pageInfo = tail.isNotEmpty && currentPageInfo != null + ? MailPageInfo( + hasNext: currentPageInfo.hasNext, + nextCursor: currentPageInfo.nextCursor, + total: fetchedPageInfo.total, + ) + : fetchedPageInfo; + + return ( + items: List.unmodifiable([ + ...fetched, + ...tail, + ]), + pageInfo: pageInfo, + ); +} diff --git a/flutter/lib/features/mail/domain/mailbox_tree.dart b/flutter/lib/features/mail/domain/mailbox_tree.dart new file mode 100644 index 0000000..1ab4472 --- /dev/null +++ b/flutter/lib/features/mail/domain/mailbox_tree.dart @@ -0,0 +1,136 @@ +/// 메일함 차례 · 묶음 · 옮길 곳 — 순수 함수 (웹 `shared/utils/mailbox.ts` 를 옮겼다). +/// +/// 이름 차례만은 웹과 어긋날 수 있다 — 웹은 `localeCompare(…, 'ko')`(ICU 한국어 정렬)이고, 앱은 대소문자를 가리지 않고 +/// 글자 값으로 견준다. 영문 · 한글이 섞인 이름("ARA 프로젝트" · "가나다" · "erp")에서 차례가 다를 수 있다(2026-11-06 리뷰에서 정함). +library; + +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:flutter/foundation.dart'; + +/// 메일함 정렬 차례 — 계약 2절 "메일함 정렬(확정 디자인 기준)". 역할 없는 메일함은 이 뒤에 sortOrder · 이름 순 +const List kMailboxRoleOrder = [ + MailboxRole.inbox, + MailboxRole.sent, + MailboxRole.drafts, + MailboxRole.junk, + MailboxRole.trash, + MailboxRole.archive, +]; + +/// 서랍에 그릴 한 줄 — [depth] 는 하위 메일함 들여쓰기 단계 +@immutable +class MailboxNode { + const MailboxNode(this.mailbox, this.depth); + + final Mailbox mailbox; + final int depth; +} + +int _roleRank(MailboxRole? role) { + if (role == null) return kMailboxRoleOrder.length; + final int index = kMailboxRoleOrder.indexOf(role); + return index == -1 ? kMailboxRoleOrder.length : index; +} + +/// 역할 차례 → sortOrder → 이름(대소문자를 가리지 않는다 — 같으면 원래 글자로 한 번 더 견줘 차례를 늘 같게) +int compareMailboxes(Mailbox a, Mailbox b) { + final int byRole = _roleRank(a.role) - _roleRank(b.role); + if (byRole != 0) return byRole; + if (a.sortOrder != b.sortOrder) return a.sortOrder - b.sortOrder; + final int byName = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + return byName != 0 ? byName : a.name.compareTo(b.name); +} + +/// 부모 · 자식 관계를 지키며 정렬해 한 줄로 편다. +/// 부모를 찾을 수 없거나 서로를 가리키는(순환) 메일함은 맨 위로 올려 빠뜨리지 않는다 +List orderMailboxes(List mailboxes) { + final Set ids = { + for (final Mailbox mailbox in mailboxes) mailbox.id, + }; + final Map> childrenOf = >{}; + for (final Mailbox mailbox in mailboxes) { + final String? parentId = mailbox.parentId; + final String? key = parentId != null && ids.contains(parentId) + ? parentId + : null; + (childrenOf[key] ??= []).add(mailbox); + } + + final List result = []; + final Set visited = {}; + void visit(String? parentId, int depth) { + final List children = [...?childrenOf[parentId]] + ..sort(compareMailboxes); + for (final Mailbox mailbox in children) { + if (!visited.add(mailbox.id)) continue; + result.add(MailboxNode(mailbox, depth)); + visit(mailbox.id, depth + 1); + } + } + + visit(null, 0); + // 순환(a→b→a)으로 어느 뿌리에도 닿지 않은 메일함 + final List orphans = + mailboxes + .where((Mailbox mailbox) => !visited.contains(mailbox.id)) + .toList() + ..sort(compareMailboxes); + for (final Mailbox mailbox in orphans) { + if (!visited.add(mailbox.id)) continue; + result.add(MailboxNode(mailbox, 0)); + visit(mailbox.id, 1); + } + return result; +} + +/// 서랍의 두 묶음 — 기본 메일함(역할이 있는 뿌리와 그 아래)과 "내 메일함"(역할 없는 뿌리와 그 아래) +({List system, List mine}) splitMailboxGroups( + List nodes, +) { + final List system = []; + final List mine = []; + bool rootHasRole = false; + for (final MailboxNode node in nodes) { + if (node.depth == 0) rootHasRole = node.mailbox.role != null; + (rootHasRole ? system : mine).add(node); + } + return (system: system, mine: mine); +} + +/// 역할의 메일함 — 없으면 null +Mailbox? findMailboxByRole(List mailboxes, MailboxRole role) { + for (final Mailbox mailbox in mailboxes) { + if (mailbox.role == role) return mailbox; + } + return null; +} + +/// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함 +Mailbox? defaultMailboxOf(List mailboxes) { + final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox); + if (inbox != null) return inbox; + final List ordered = orderMailboxes(mailboxes); + return ordered.isEmpty ? null : ordered.first.mailbox; +} + +/// 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함(서랍 차례). 휴지통으로는 [삭제]로 옮긴다 +/// (웹 `useBulkMailActions` 의 `moveTargets` 와 같다) +List moveTargetsOf( + List mailboxes, + String? currentMailboxId, +) { + return orderMailboxes(mailboxes) + .where( + (MailboxNode node) => + node.mailbox.id != currentMailboxId && + node.mailbox.role != MailboxRole.drafts && + node.mailbox.role != MailboxRole.trash, + ) + .toList(); +} + +/// 위 막대 아래에 "안 읽은 메일 N통"을 보이는 메일함 — 보낸편지함 · 임시보관함 · 휴지통은 안 읽음이 뜻이 없어 뺀다 +bool showsUnreadCount(MailboxRole? role) => + role != MailboxRole.sent && + role != MailboxRole.drafts && + role != MailboxRole.trash; diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart index b3f0a68..407255d 100644 --- a/flutter/lib/features/mail/mail_routes.dart +++ b/flutter/lib/features/mail/mail_routes.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; @@ -10,7 +11,7 @@ abstract final class MailRoutes { /// 첫 화면 — 받은편지함 목록 (탭 안) static final String home = findAppModule(AppModuleId.mail).rootPath; - /// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다) + /// 다른 메일함의 목록 (탭 안 — 받은편지함 위에 쌓인다. 뒤로 가기는 받은편지함, 탭을 바꿔도 이 기록이 남는다) static String mailbox(String mailboxId) => '$home/mailboxes/${Uri.encodeComponent(mailboxId)}'; @@ -20,11 +21,20 @@ abstract final class MailRoutes { /// 메일 쓰기 (전체 화면 — 탭 막대 없이) static String get compose => '$home/compose'; + + /// 임시 저장본 이어 쓰기 — 임시보관함의 줄을 누르면(웹과 같다 — 주소 `?draft=`) + static String composeDraft(String draftId) => Uri( + path: compose, + queryParameters: {draftQuery: draftId}, + ).toString(); + + /// 이어 쓸 임시 저장본 id 의 쿼리 이름 (웹 `/mail/compose?draft=` 와 같다) + static const String draftQuery = 'draft'; } /// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. /// -/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면 +/// 목록은 앱 보드 `App1MailList` 화면이다. 읽기 · 쓰기는 아직 자리 화면이다 — 보드(`App1Reader` · `App1Compose`)대로 만들면 /// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다). final ModuleRoutes mailModuleRoutes = ModuleRoutes( id: AppModuleId.mail, @@ -33,20 +43,12 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes( buildRoutes: (ModuleRouteContext context) => [ GoRoute( path: MailRoutes.home, - builder: (BuildContext _, GoRouterState _) => PlaceholderPage( - title: MailTexts.home, - links: [ - PlaceholderLink( - label: MailTexts.compose, - location: MailRoutes.compose, - ), - ], - ), + builder: (BuildContext _, GoRouterState _) => const MailListPage(), routes: [ GoRoute( path: 'mailboxes/:mailboxId', - builder: (BuildContext _, GoRouterState _) => - const PlaceholderPage(title: MailTexts.mailbox), + builder: (BuildContext _, GoRouterState state) => + MailListPage(mailboxId: state.pathParameters['mailboxId']), ), GoRoute( path: 'messages/:messageId', diff --git a/flutter/lib/features/mail/presentation/mail_row_view.dart b/flutter/lib/features/mail/presentation/mail_row_view.dart new file mode 100644 index 0000000..2a6462e --- /dev/null +++ b/flutter/lib/features/mail/presentation/mail_row_view.dart @@ -0,0 +1,196 @@ +/// 목록 줄 하나의 표시 — 순수 함수 (웹 `shared/utils/mail-list-row.ts` · `format.ts` 의 분할 보기 규칙과 같다). +/// +/// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않는다(누르면 쓰기 화면에서 이어 쓴다). +/// - 보낸편지함: "받는 사람: 박준호 외 2명". +/// - 아직 보내지 않은 내 메일(9단계 `pendingSend`)은 시각 자리가 "보내는 중" · "예약 · 11월 7일 오전 9:00"(청록)이고 고를 수 없다. +library; + +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:flutter/foundation.dart'; + +/// 목록 줄 하나에 보일 것 +@immutable +class MailRowView { + const MailRowView({ + required this.who, + required this.whoPlaceholder, + required this.initial, + required this.subject, + required this.subjectPlaceholder, + required this.preview, + required this.time, + required this.timePending, + required this.unread, + required this.flagged, + required this.hasAttachment, + required this.held, + }); + + /// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람 + final String who; + + /// [who] 가 빈 값을 알리는 글이다 — 흐리게 + final bool whoPlaceholder; + + /// 동그란 사진의 머리글자 — 사람이 없으면 null(사람 아이콘) + final String? initial; + final String subject; + + /// 임시 저장본의 빈 제목 — 흐리게 + final bool subjectPlaceholder; + final String preview; + + /// 받은 시각 · 붙잡아 둔 발송의 표시 + final String time; + + /// [time] 이 아직 보내지 않은 메일의 표시다 — 청록 글 + final bool timePending; + final bool unread; + final bool flagged; + final bool hasAttachment; + + /// 아직 보내지 않은 메일 — 고를 수 없다 + final bool held; + + /// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목" + String get selectLabel => MailListTexts.selectOne(subject); + + /// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례 + String get semanticsLabel => [ + if (unread) MailListTexts.unreadPrefix, + who, + time, + subject, + if (hasAttachment) MailListTexts.attachment, + if (flagged) MailListTexts.flagged, + if (preview.isNotEmpty) preview, + ].join(', '); +} + +/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다) +MailRowView mailRowViewOf( + MessageSummary message, { + required MailboxRole? role, + required DateTime now, +}) { + final bool draft = role == MailboxRole.drafts; + final ({String text, bool placeholder, String? initial}) who = _whoOf( + message, + role, + ); + final PendingSend? pending = message.pendingSend; + return MailRowView( + who: who.text, + whoPlaceholder: who.placeholder, + initial: who.initial, + subject: message.subject.isEmpty + ? MailListTexts.noSubject + : message.subject, + subjectPlaceholder: draft && message.subject.isEmpty, + preview: message.preview, + time: pending == null + ? formatMailListTime(message.receivedAt, now) + : pendingSendLabel(pending), + timePending: pending != null, + unread: !draft && !message.isRead, + flagged: message.isFlagged, + hasAttachment: message.hasAttachment, + held: message.isHeld, + ); +} + +({String text, bool placeholder, String? initial}) _whoOf( + MessageSummary message, + MailboxRole? role, +) { + final List to = message.to; + final MailAddress? firstRecipient = to.isEmpty ? null : to.first; + switch (role) { + case MailboxRole.drafts: + final String names = to + .map((MailAddress address) => address.displayName) + .where((String name) => name.isNotEmpty) + .join(', '); + if (names.isEmpty || firstRecipient == null) { + return ( + text: MailListTexts.draftNoRecipients, + placeholder: true, + initial: null, + ); + } + return ( + text: names, + placeholder: false, + initial: initialOf(firstRecipient.name, fallback: firstRecipient.email), + ); + case MailboxRole.sent: + if (firstRecipient == null) { + return ( + text: MailListTexts.noRecipients, + placeholder: false, + initial: null, + ); + } + return ( + text: + '${MailListTexts.recipientPrefix}${MailListTexts.recipientsSummary(firstRecipient.displayName, to.length - 1)}', + placeholder: false, + initial: initialOf(firstRecipient.name, fallback: firstRecipient.email), + ); + case MailboxRole.inbox: + case MailboxRole.junk: + case MailboxRole.trash: + case MailboxRole.archive: + case null: + final MailAddress? from = message.from; + if (from == null) { + return ( + text: MailListTexts.noSender, + placeholder: false, + initial: null, + ); + } + return ( + text: from.displayName, + placeholder: false, + initial: initialOf(from.name, fallback: from.email), + ); + } +} + +/// 이름 앞의 "(주)" · "[공지]" 같은 묶음 — 머리글자에서 뺀다 +final RegExp _leadingBracket = RegExp(r'^\s*[(\[][^)\]]*[)\]]\s*'); + +/// 동그란 사진의 머리글자 — "(주)한빛솔루션" → "한", 이름이 없으면 주소의 첫 글자(웹 `initialOf` 와 같다). 둘 다 비면 null +String? initialOf(String? name, {String fallback = ''}) { + final String cleaned = (name ?? '').replaceFirst(_leadingBracket, '').trim(); + final String source = cleaned.isNotEmpty ? cleaned : fallback.trim(); + if (source.isEmpty) return null; + return String.fromCharCode(source.runes.first).toUpperCase(); +} + +/// 목록의 받은 시각 — 오늘 "오전 10:42" · 어제 "어제" · 올해 "11월 3일" · 그 전 "2025. 10. 24." (웹 `formatMailListTime`). +/// 기기 시간대로 본다 +String formatMailListTime(DateTime at, DateTime now) { + final DateTime local = at.toLocal(); + final DateTime today = now.toLocal(); + if (isSameCalendarDay(local, today)) return koreanTimeOfDay(local); + if (isSameCalendarDay(local, previousCalendarDay(today))) { + return MailListTexts.yesterday; + } + if (local.year == today.year) return koreanMonthDay(local); + return koreanNumericDate(local); +} + +/// 붙잡아 둔 발송의 표시 — 예약 "예약 · 11월 7일 오전 9:00"(요일 없이 — 7일 안이라 연도도 없다), 보내기 취소를 기다리는 동안 "보내는 중" +String pendingSendLabel(PendingSend pending) { + if (pending.kind == PendingSendKind.undo) return MailListTexts.sending; + final DateTime local = pending.sendAt.toLocal(); + return MailListTexts.scheduled( + '${koreanMonthDay(local)} ${koreanTimeOfDay(local)}', + ); +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 372384f..2090b8c 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -1,16 +1,184 @@ -/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다. +/// 메일 모듈의 화면 글 — 원본은 앱 보드 `App1MailList`(`docs/design/webmail/App1MailList.dc.html`)다. /// -/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준). +/// 보드에 없는 상태(여러 통 처리의 알림 · 영구 삭제 확인 · 받지 못함 · 붙잡힌 메일 등)의 글은 웹 상수 +/// (`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다. +/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; + +/// 메일 모듈의 화면 이름 — 아직 자리 화면인 읽기 · 쓰기가 쓴다 abstract final class MailTexts { - /// 메일 첫 화면 — 받은편지함 목록 - static const String home = '메일'; - - /// 다른 메일함의 목록 (탭 안) - static const String mailbox = '메일함'; - /// 메일 읽기 (전체 화면) static const String message = '메일 읽기'; /// 메일 쓰기 (전체 화면) static const String compose = '메일 쓰기'; } + +/// 목록 화면 (보드 `App1MailList` 「받은편지함」 · 「당겨서 새로고침」 · 「빈 메일함 (휴지통)」) +abstract final class MailListTexts { + // ── 위 막대 ── + static const String openDrawer = '메일함 열기'; + static const String search = '메일 찾기'; + + /// [찾기]를 눌렀을 때 — 검색 화면은 다음 차례다(보드에 없다) + static const String searchNotReady = '메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요.'; + + /// 메일함 이름을 받기 전의 제목 — 받은편지함 주소(`/mail`)는 [inbox] + static const String mailboxFallback = '메일함'; + + /// 받은편지함 — 메일함 목록을 받기 전에도 첫 화면 제목은 이것이다(서버의 표준 이름과 같다) + static const String inbox = '받은편지함'; + + /// 제목 아래 — "안 읽은 메일 3통" · "안 읽은 메일이 없습니다" + static String unread(int count) => + count > 0 ? '안 읽은 메일 ${formatCount(count)}통' : '안 읽은 메일이 없습니다'; + + /// 목록의 화면 낭독기 이름 — "받은편지함 메일" + static String listLabel(String mailboxName) => '$mailboxName 메일'; + + // ── 줄 ── + /// 안 읽은 줄의 앞 — 화면 낭독기만 읽는다(뱃지 없이 굵은 글자로만 가른다) + static const String unreadPrefix = '안 읽음'; + static const String attachment = '첨부 있음'; + static const String flagged = '중요'; + + /// 동그란 사진(고르기) — "고르기: 제목" + static String selectOne(String subject) => '고르기: $subject'; + static const String noSubject = '(제목 없음)'; + static const String noSender = '(보낸 사람 없음)'; + + /// 보낸편지함 — 받는 사람이 없을 때 + static const String noRecipients = '받는 사람 없음'; + + /// 임시보관함 — 받는 사람이 없을 때(흐린 글) + static const String draftNoRecipients = '(받는 사람 없음)'; + + /// 보낸편지함 줄의 앞 — "받는 사람: 박준호 외 2명" + static const String recipientPrefix = '받는 사람: '; + static String recipientsSummary(String first, int others) => + others > 0 ? '$first 외 ${formatCount(others)}명' : first; + static const String yesterday = '어제'; + + /// 아직 보내지 않은 내 메일 — 시각 자리 + static const String sending = '보내는 중'; + static String scheduled(String time) => '예약 · $time'; + + /// 아직 보내지 않은 메일을 고르려 할 때 (웹 `heldNotSelectable`) + static const String heldNotSelectable = + '보내기를 기다리는 메일은 고를 수 없습니다. 메일을 열어 취소하거나 삭제해 주세요.'; + + // ── 새로고침 · 불러오기 ── + /// 당겨서 새로고침 — 화면 낭독기 + static const String refreshing = '새 메일을 확인하는 중'; + + /// 처음 불러오는 동안 — 화면 낭독기 + static const String loading = '메일 목록 불러오는 중'; + + /// 다음 쪽을 받는 동안 — 화면 낭독기 + static const String loadingMore = '메일을 더 불러오는 중'; + + // ── 받지 못함 ── + static const String retry = '다시 시도'; + + /// 없는 메일함(주소에 남은 옛 id) — 다시 해도 같아 빠져나갈 단추를 준다 + static const String toInbox = '받은편지함으로'; + static String traceCode(String code) => '문의 코드 $code'; + + // ── 빈 메일함 ── + /// "휴지통이 비어 있습니다" + static String emptyTitle(String mailboxName) => + '$mailboxName${subjectParticle(mailboxName)} 비어 있습니다'; + + /// 메일함마다 빈 메일함 안내 — 키 null 은 사용자가 만든 메일함 + static String emptyHint(MailboxRole? role) => switch (role) { + MailboxRole.inbox => '새 메일이 오면 여기에 표시됩니다.', + MailboxRole.drafts => '임시 저장한 메일이 여기에 표시됩니다.', + MailboxRole.sent => '보낸 메일이 여기에 표시됩니다.', + MailboxRole.junk => '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.', + MailboxRole.trash => '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.', + MailboxRole.archive => '보관한 메일이 여기에 표시됩니다.', + null => '이 메일함으로 옮긴 메일이 여기에 표시됩니다.', + }; + + /// 휴지통 · 스팸메일함 목록 위의 30일 줄 (웹 13단계 — 메일이 없으면 빈 안내에 붙인다) + static String? retentionNotice(MailboxRole? role) => switch (role) { + MailboxRole.trash => '휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.', + MailboxRole.junk => '스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.', + _ => null, + }; + + // ── [메일 쓰기] ── + static const String compose = '메일 쓰기'; +} + +/// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」) +abstract final class MailDrawerTexts { + static const String title = '메일함'; + static const String myMailboxes = '내 메일함'; + + /// 서랍 밖(가림막)을 눌러 닫을 때 — 화면 낭독기 (보드에 없다) + static const String close = '메일함 닫기'; + + /// 안 읽은 수 — 화면 낭독기는 "안 읽음 3"으로 읽는다(웹과 같다) + static String unreadCount(int count) => '안 읽음 ${formatCount(count)}'; +} + +/// 여러 통 고르기 (보드 `App1MailList` 「길게 눌러 고르기」 · 웹 3단계 "여러 통 처리") +abstract final class MailBulkTexts { + /// 고르기 막대 — 화면 낭독기 + static const String toolbar = '고른 메일'; + static String selected(int count) => '${formatCount(count)}통 선택됨'; + static const String clear = '선택 해제'; + static const String markRead = '읽음 표시'; + + /// 고른 것이 모두 읽음이면 [markRead] 대신 + static const String markUnread = '안 읽음 표시'; + static const String flag = '중요 표시'; + + /// 고른 것이 모두 중요면 [flag] 대신 + static const String unflag = '중요 해제'; + static const String move = '이동'; + + /// 이동 시트의 제목 + static const String moveSheetTitle = '옮길 메일함'; + static const String delete = '삭제'; + + /// 휴지통에서는 [delete] 대신 + static const String purge = '영구 삭제'; + static const String undo = '되돌리기'; + + /// "메일 3통을 휴지통으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' + static String moved( + int done, + String mailboxName, { + int skipped = 0, + }) => _withSkipped( + '메일 ${formatCount(done)}통을 $mailboxName${directionParticle(mailboxName)} 옮겼습니다.', + done, + skipped, + ); + + static String purged(int done, {int skipped = 0}) => + _withSkipped('메일 ${formatCount(done)}통을 영구 삭제했습니다.', done, skipped); + + static String restored(int done, {int skipped = 0}) => + _withSkipped('메일 ${formatCount(done)}통을 되돌렸습니다.', done, skipped); + + /// 일부를 처리하지 못했으면 뒤에 덧붙인다 — 하나도 못 했으면 그 문장만 + static String _withSkipped(String text, int done, int skipped) { + if (skipped <= 0) return text; + final String suffix = '${formatCount(skipped)}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.'; + return done > 0 ? '$text $suffix' : suffix; + } + + // ── 영구 삭제 확인 (휴지통에서만 — 처음 초점은 [취소]) ── + static String purgeTitle(int count) => '메일 ${formatCount(count)}통을 영구 삭제할까요?'; + static const String purgeMessage = '휴지통에서 지운 메일은 되돌릴 수 없습니다.'; + static const String purgeConfirm = '영구 삭제'; + static const String purgeCancel = '취소'; +} diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart new file mode 100644 index 0000000..51fe5d0 --- /dev/null +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -0,0 +1,393 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/shell_bar.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_app_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 메일 목록 — 앱 보드 `App1MailList` (앱 디자인 README 「메일 목록」). +/// +/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(아직 준비 중 — 알림만). 서랍은 탭 막대 위까지 덮는다. +/// - 줄을 누르면 읽기 화면(전체 화면 — 임시보관함은 쓰기 화면에서 이어 쓴다). 읽음 처리는 읽기 화면이 한다. +/// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다. +/// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인). +/// - 화면이 보이는지(다른 탭 · 전체 화면 · 위에 쌓인 메일함에 가렸는지)는 TickerMode 로 안다. +class MailListPage extends ConsumerStatefulWidget { + const MailListPage({this.mailboxId, super.key}); + + /// 주소의 메일함 id — null 이면 받은편지함(주소 `/mail`) + final String? mailboxId; + + @override + ConsumerState createState() => _MailListPageState(); +} + +class _MailListPageState extends ConsumerState { + /// 컨트롤러에 마지막으로 알린 보임 — 그린 뒤에 알린다(그리는 동안에는 프로바이더를 바꿀 수 없다) + bool? _reportedVisible; + bool _wantVisible = false; + + MailListController get _controller => + ref.read(mailListProvider(widget.mailboxId).notifier); + + @override + void didUpdateWidget(MailListPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mailboxId != widget.mailboxId) _reportedVisible = null; + } + + void _scheduleVisibilityReport() { + if (_reportedVisible == _wantVisible) return; + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + if (!mounted || _reportedVisible == _wantVisible) return; + _reportedVisible = _wantVisible; + _controller.setVisible(_wantVisible); + }); + } + + // ── 하는 일 ── + + Future _openDrawer(String? currentMailboxId) async { + final Mailbox? chosen = await showMailboxDrawer( + context, + currentMailboxId: currentMailboxId, + ); + if (chosen == null || !mounted || chosen.id == currentMailboxId) return; + context.go( + chosen.role == MailboxRole.inbox + ? MailRoutes.home + : MailRoutes.mailbox(chosen.id), + ); + } + + void _showSearchNotice() { + ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady); + } + + void _toggle(MessageSummary message) { + if (_controller.toggleSelection(message.id)) return; + if (message.isHeld) { + ref + .read(mailToastProvider.notifier) + .notify(MailListTexts.heldNotSelectable); + } + } + + void _open(MessageSummary message, MailboxRole? role) { + unawaited( + context.push( + role == MailboxRole.drafts + ? MailRoutes.composeDraft(message.id) + : MailRoutes.message(message.id), + ), + ); + } + + Future _move(List mailboxes, String? currentMailboxId) async { + final List targets = moveTargetsOf( + mailboxes, + currentMailboxId, + ); + if (targets.isEmpty) return; + final Mailbox? chosen = await showMailboxPickerSheet( + context, + targets: targets, + ); + if (chosen == null || !mounted) return; + await _controller.moveSelected(chosen); + } + + Future _delete({required bool inTrash, required int count}) async { + if (!inTrash) { + await _controller.moveSelectedToTrash(); + return; + } + final bool confirmed = await showConfirmDialog( + context, + title: MailBulkTexts.purgeTitle(count), + message: MailBulkTexts.purgeMessage, + confirmLabel: MailBulkTexts.purgeConfirm, + cancelLabel: MailBulkTexts.purgeCancel, + danger: true, + ); + if (!confirmed || !mounted) return; + await _controller.purgeSelected(); + } + + @override + Widget build(BuildContext context) { + final MailListState list = ref.watch(mailListProvider(widget.mailboxId)); + final MailboxesState mailboxes = ref.watch(mailboxesProvider); + final Mailbox? mailbox = mailboxes.byId(list.mailboxId); + final bool inboxRoute = widget.mailboxId == null; + final MailboxRole? role = + mailbox?.role ?? (inboxRoute ? MailboxRole.inbox : null); + final String title = + mailbox?.name ?? + (inboxRoute ? MailListTexts.inbox : MailListTexts.mailboxFallback); + final bool selecting = list.selecting; + final bool inTrash = role == MailboxRole.trash; + // 주소의 메일함을 메일함 목록과 맞춘 뒤 그린다 — 역할을 알기 전에는 역할에 기대는 것 + // (고르기 막대의 삭제 · 임시보관함 줄 열기 · 보낸편지함 줄 모양 · 30일 줄)을 그리지 않는다 + final bool mailboxesReady = mailboxes.status == MailboxesStatus.ready; + final bool unknownMailbox = + mailboxesReady && !inboxRoute && mailbox == null; + + _wantVisible = TickerMode.valuesOf(context).enabled; + _scheduleVisibilityReport(); + + final PreferredSizeWidget appBar = selecting + ? MailSelectionBar( + count: list.selectedCount, + allRead: list.allSelectedRead, + allFlagged: list.allSelectedFlagged, + inTrash: inTrash, + busy: list.busy, + onClear: _controller.clearSelection, + onToggleRead: () => unawaited(_controller.toggleSelectedRead()), + onToggleFlag: () => unawaited(_controller.toggleSelectedFlag()), + onMove: () => unawaited(_move(mailboxes.items, list.mailboxId)), + onDelete: () => + unawaited(_delete(inTrash: inTrash, count: list.selectedCount)), + ) + : MailListAppBar( + title: title, + subtitle: mailbox != null && showsUnreadCount(role) + ? MailListTexts.unread(mailbox.unreadCount) + : null, + onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)), + onSearch: _showSearchNotice, + ); + + return PopScope( + canPop: !selecting, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (!didPop) _controller.clearSelection(); + }, + child: ShellBarHidden( + module: AppModuleId.mail, + hidden: selecting, + child: Scaffold( + appBar: appBar, + body: _MailListBody( + list: list, + role: role, + title: title, + mailboxesReady: mailboxesReady, + mailboxesFailure: mailboxes.failure, + unknownMailbox: unknownMailbox, + onRefresh: _controller.refresh, + onRetry: () => unawaited(_controller.retry()), + onRetryMailboxes: () => + unawaited(ref.read(mailboxesProvider.notifier).reload()), + onLeave: inboxRoute ? null : () => context.go(MailRoutes.home), + onLoadMore: () => unawaited(_controller.loadMore()), + onRowTap: (MessageSummary message) => + selecting ? _toggle(message) : _open(message, role), + onRowSelect: _toggle, + ), + floatingActionButton: MailComposeArea( + key: const ValueKey('mail-compose-area'), + showCompose: !selecting, + safeInsets: MediaQuery.paddingOf(context), + onCompose: () => unawaited(context.push(MailRoutes.compose)), + ), + floatingActionButtonLocation: const MailComposeAreaLocation(), + ), + ), + ); + } +} + +/// 목록 자리 — 불러오는 중 · 받지 못함 · 빈 메일함 · 목록. 어느 상태에서도 당겨서 새로고침이 된다. +/// 메일함 목록(역할)을 받기 전에는 목록을 그리지 않고 뼈대를, 받지 못했으면 그 실패를, 주소의 메일함이 없으면 `RES_001` 을 보인다 +class _MailListBody extends StatelessWidget { + const _MailListBody({ + required this.list, + required this.role, + required this.title, + required this.mailboxesReady, + required this.mailboxesFailure, + required this.unknownMailbox, + required this.onRefresh, + required this.onRetry, + required this.onRetryMailboxes, + required this.onLeave, + required this.onLoadMore, + required this.onRowTap, + required this.onRowSelect, + }); + + final MailListState list; + final MailboxRole? role; + final String title; + final bool mailboxesReady; + final ApiException? mailboxesFailure; + final bool unknownMailbox; + final Future Function() onRefresh; + final VoidCallback onRetry; + final VoidCallback onRetryMailboxes; + final VoidCallback? onLeave; + final VoidCallback onLoadMore; + final ValueChanged onRowTap; + final ValueChanged onRowSelect; + + @override + Widget build(BuildContext context) { + final ColorScheme scheme = Theme.of(context).colorScheme; + final ApiException? boxesFailure = mailboxesFailure; + const Widget skeleton = _SkeletonList(); + final Widget content = switch (list.status) { + _ when boxesFailure != null => _FillViewport( + child: MailListError(error: boxesFailure, onRetry: onRetryMailboxes), + ), + _ when unknownMailbox => _FillViewport( + child: MailListError( + error: ApiException.fromCode('RES_001'), + onRetry: onRetry, + onLeave: onLeave, + ), + ), + _ when !mailboxesReady => skeleton, + MailListStatus.loading => skeleton, + MailListStatus.failed => _FillViewport( + child: MailListError( + error: list.error!, + onRetry: onRetry, + onLeave: onLeave, + ), + ), + MailListStatus.ready when list.items.isEmpty => _FillViewport( + child: MailListEmpty(mailboxName: title, role: role), + ), + MailListStatus.ready => _MessageList( + list: list, + role: role, + title: title, + onLoadMore: onLoadMore, + onRowTap: onRowTap, + onRowSelect: onRowSelect, + ), + }; + return RefreshIndicator( + onRefresh: onRefresh, + color: scheme.primary, + backgroundColor: scheme.surface, + semanticsLabel: MailListTexts.refreshing, + child: content, + ); + } +} + +/// 처음 불러오는 동안 — 뼈대 줄(당겨서 새로고침이 되게 스크롤 안에) +class _SkeletonList extends StatelessWidget { + const _SkeletonList(); + + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [MailListSkeleton()], + ); + } +} + +/// 빈 메일함 · 받지 못함 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다 +class _FillViewport extends StatelessWidget { + const _FillViewport({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: child)), + ], + ); + } +} + +class _MessageList extends ConsumerWidget { + const _MessageList({ + required this.list, + required this.role, + required this.title, + required this.onLoadMore, + required this.onRowTap, + required this.onRowSelect, + }); + + final MailListState list; + final MailboxRole? role; + final String title; + final VoidCallback onLoadMore; + final ValueChanged onRowTap; + final ValueChanged onRowSelect; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다 + final DateTime now = ref.read(clockProvider)(); + final String? notice = MailListTexts.retentionNotice(role); + final int noticeCount = notice == null ? 0 : 1; + final bool footer = list.hasMore || list.loadingMore || list.loadMoreFailed; + final List items = list.items; + return Semantics( + container: true, + explicitChildNodes: true, + label: MailListTexts.listLabel(title), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + // 마지막 줄이 [메일 쓰기]에 가리지 않게 (보드의 목록 아래 여백 96) + padding: const EdgeInsets.only( + bottom: AppSize.fabHeight + AppSpacing.s40, + ), + itemCount: noticeCount + items.length + (footer ? 1 : 0), + itemBuilder: (BuildContext context, int index) { + if (notice != null && index == 0) return MailRetentionNotice(notice); + final int row = index - noticeCount; + if (row >= items.length) { + return MailListFooter( + loading: list.loadingMore, + failed: list.loadMoreFailed, + onReached: onLoadMore, + onRetry: onLoadMore, + ); + } + final MessageSummary message = items[row]; + return MessageRow( + key: ValueKey(message.id), + view: mailRowViewOf(message, role: role, now: now), + selected: list.selectedIds.contains(message.id), + onTap: () => onRowTap(message), + onLongPress: () => onRowSelect(message), + onAvatarTap: () => onRowSelect(message), + ); + }, + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart new file mode 100644 index 0000000..419ca75 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_list_controller.dart @@ -0,0 +1,914 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/mail_bulk.dart'; +import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +/// 새 메일 확인으로 맞출 때 다시 받는 최대 수 — 불러온 만큼, 그보다 많이 불러왔으면 이만큼만(웹 `MAIL_LIST_SYNC_MAX`) +const int kMailListSyncMax = 100; + +/// 보낼 시각 뒤 이만큼 기다렸다 목록을 다시 본다 — 엔진이 발송을 풀어도 새 메일 확인의 상태는 바뀌지 않는다 +/// (계약 2절 "보내기 취소 · 예약 발송" — 웹 `PENDING_RELEASE_GRACE_MS`) +const Duration kHeldReleaseGrace = Duration(seconds: 2); + +/// 다시 본 뒤에도 시각이 지난 채 붙잡힌 것으로 보이면 이 간격으로 다시 본다(웹 `PENDING_RELEASE_RECHECK_MS`) +const Duration kHeldReleaseRecheck = Duration(seconds: 5); + +/// 그렇게 더 보는 횟수 — 넘으면 그 발송은 그만 본다(다음 새 메일 확인 · 새로고침이 맞춘다) +const int kHeldReleaseMaxRechecks = 3; + +/// 메일함 하나의 목록 — 주소의 메일함 id 마다 하나(null 은 받은편지함 — 주소 `/mail`). +/// 목록 화면이 있는 동안만 둔다(autoDispose) — 메일함을 바꾸면 앞 목록은 사라지고 그 응답은 버린다. +final NotifierProviderFamily +mailListProvider = NotifierProvider.autoDispose + .family(MailListController.new); + +/// 읽음 · 중요 표시의 바꿀 값 +typedef _FlagChange = ({bool? isRead, bool? isFlagged}); + +/// 보내는 중인 표시 하나 — 값과 그것을 보낸 요청(같은 칸을 잇달아 바꾸면 마지막 요청이 주인이다) +typedef _PendingFlag = ({bool value, int owner}); + +/// 메일함 하나의 목록 — 계약 2절 `GET /api/messages` · "여러 통 한꺼번에" · "새 메일 확인" (웹 `stores/mail/list.ts` · `selection.ts` · `bulk.ts` 와 같은 규칙) +/// +/// 늦게 온 응답 막기: +/// - 세대([_listSeq]): 처음부터 다시 받을 때마다(다시 시도 · 당겨서 새로고침 · 세션이 바뀜) 오른다 — 그 전에 보낸 목록 요청의 응답은 버린다. +/// - 이 목록이 바꾼 횟수([_localGeneration]): 읽음 · 중요 표시 · 빼기 · 이어 받기마다 — 그 전에 떠난 맞추기의 응답은 옛 상태라 버리고 한 번 더 맞춘다. +/// - 맞추기는 한 번에 하나다([_syncRun]) — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞춘다(늦게 온 옛 응답이 새 응답을 덮지 않게). +/// - 보내는 중인 읽음 · 중요 표시([_pendingRead] · [_pendingFlagged])는 그사이 받은 목록 위에 덮어쓴다(옛 값으로 되돌아 보이지 않게). +/// 칸마다 마지막으로 보낸 요청이 주인이다 — 앞 요청이 끝나도 뒤 요청의 표시는 남는다. +/// - 비동기 일은 시작할 때의 [Ref] 를 쥐고, 끝난 뒤 그 Ref 가 살아 있을 때만 이 목록을 바꾼다(다시 만들어졌거나 사라졌으면 버린다). +/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 개수 · 알림 · [되돌리기]도 하지 않는다 — 끝난 세션의 결과를 새 세션에 보이지 않게. +/// +/// 여러 통 처리: +/// - 고를 수 있는 것은 불러온 메일까지, 아직 보내지 않은 메일(붙잡힌 발송)은 고를 수 없다. +/// 목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다(계약 2절 "메일함을 바꾸면 선택을 푼다"). +/// - 읽음 · 중요 표시는 화면을 먼저 바꾸고 고르기를 끝낸다(앱 보드) — 서버가 바꾸지 못한 메일(`failedIds`) · 보내지 못한 메일만 되돌리고, +/// 통째로 실패했으면 고른 것도 되살린다(다시 해 볼 수 있게). +/// - 이동 · 삭제는 응답의 id 만 뺀다. 하나라도 처리했으면 고르기를 끝내고, 통째로 실패했으면 고른 것을 그대로 둔다. +/// - 휴지통 밖의 [삭제]는 휴지통으로 옮기기(+ [되돌리기]), 휴지통에서는 확인 뒤 영구 삭제(`POST /api/message-deletions`) — 웹처럼 `DELETE` 는 쓰지 않는다. +/// - 100통씩 차례로 보낸다. +class MailListController extends Notifier { + MailListController(this.route); + + /// 주소의 메일함 id — null 이면 받은편지함 + final String? route; + + int _listSeq = 0; + int _localGeneration = 0; + final Map _pendingRead = {}; + final Map _pendingFlagged = {}; + + /// 읽음 · 중요 표시 요청마다 하나씩 — 보내는 중인 표시의 주인 + int _flagRequestSeq = 0; + + /// 목록 화면이 보인다 — 다른 탭 · 전체 화면 · 위에 쌓인 메일함 아래에 가렸으면 거짓 + bool _visible = false; + + /// 가려진 동안 메일이 바뀌었다 — 다시 보이면 맞춘다 + bool _dirty = false; + MailStateWatcher? _watcher; + + /// 도는 맞추기 — 끝날 때까지 새 맞추기는 이것에 묶는다 + Future? _syncRun; + + /// 맞추는 동안 또 맞춰 달라고 했다 + bool _syncAgain = false; + + /// 서버 시계 − 기기 시계 — 받은 목록 응답의 `Date` 로 잰다(붙잡아 둔 발송이 풀릴 때를 서버 시계로 잰다) + Duration _serverOffset = Duration.zero; + Timer? _heldTimer; + final Map _heldRechecks = {}; + + MailRepository get _repository => ref.read(mailRepositoryProvider); + + Clock get _clock => ref.read(clockProvider); + + @override + MailListState build() { + final String? account = ref.watch(mailAccountProvider); + _listSeq++; + _localGeneration++; + _pendingRead.clear(); + _pendingFlagged.clear(); + _dirty = false; + _syncRun = null; + _syncAgain = false; + _serverOffset = Duration.zero; + _heldRechecks.clear(); + _cancelHeldTimer(); + ref.onDispose(_onDispose); + // 세션이 없으면(로그아웃하며 화면이 사라지는 중) 묻지 않는다 — 세션 없는 요청이 갱신을 부르지 않게 + if (account == null) return MailListState(mailboxId: route); + ref.listen(mailStateWatcherProvider, ( + MailChange? previous, + MailChange next, + ) { + // 처음부터 다시 만들 때의 0 은 바뀜이 아니다 + if (next.seq > (previous?.seq ?? 0)) _onMailChange(next); + }); + // 세션이 바뀌는 동안 아무도 듣지 않아 새 메일 확인이 새로 만들어졌을 수 있다 — 보이는 목록이면 새 것에 다시 알린다 + // (build 안에서는 다른 프로바이더를 바꿀 수 없다) + if (_visible) { + final Ref built = ref; + scheduleMicrotask(() { + if (built.mounted && _visible) _tellWatcher(); + }); + } + + final String? mailboxId = + route ?? + ref.watch( + mailboxesProvider.select((MailboxesState s) => s.defaultMailboxId), + ); + if (mailboxId == null) { + final ApiException? failure = ref.watch( + mailboxesProvider.select((MailboxesState s) => s.failure), + ); + return failure == null + ? const MailListState() + : const MailListState().failed(failure); + } + unawaited(_openFirst(mailboxId, _listSeq, silent: true)); + return MailListState(mailboxId: mailboxId); + } + + void _onDispose() { + _cancelHeldTimer(); + final MailStateWatcher? watcher = _watcher; + if (watcher == null) return; + // 생명주기 안에서는 다른 프로바이더를 바꿀 수 없다 — 끝난 뒤에, 정말 사라졌을 때만 새 메일 확인에서 뺀다 + // (다시 만들어진 것이면 Ref 가 새로 살아 있고 화면도 그대로다) + scheduleMicrotask(() { + if (!ref.mounted) watcher.setViewing(this, viewing: false); + }); + } + + // ── 화면 ───────────────────────────────────────── + + /// 목록 화면이 보이게 · 안 보이게 됐다 — 보이는 동안만 새 메일을 확인하고, 가려진 동안 바뀌었으면 다시 보일 때 맞춘다. + /// 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다 + void setVisible(bool visible) { + if (!ref.mounted) return; + final bool was = _visible; + _visible = visible; + _tellWatcher(); + if (!visible) clearSelection(); + if (visible && !was && _dirty) { + _dirty = false; + unawaited(_sync()); + } + } + + /// 새 메일 확인에 이 목록이 보이는지 알린다 — 새 메일 확인이 새로 만들어졌으면 옛것에서 빼고 새 것에 + void _tellWatcher() { + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + final MailStateWatcher? previous = _watcher; + if (previous != null && !identical(previous, watcher)) { + previous.setViewing(this, viewing: false); + } + _watcher = watcher; + watcher.setViewing(this, viewing: _visible); + } + + /// [다시 시도] — 첫 쪽을 다시 받는다. 받은편지함 주소에서 메일함 목록을 받지 못했으면 그것부터 + Future retry() async { + if (!ref.mounted) return; + final String? mailboxId = state.mailboxId; + if (mailboxId == null) { + await ref.read(mailboxesProvider.notifier).reload(); + return; + } + _listSeq++; + _localGeneration++; + state = MailListState(mailboxId: mailboxId); + await _openFirst(mailboxId, _listSeq, silent: true); + } + + /// 당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받는다(보이는 목록은 둔 채). 처음 받지 못했던 목록이면 [retry] + Future refresh() async { + if (!ref.mounted) return; + ref.read(mailboxesProvider.notifier).syncCounts(); + final String? mailboxId = state.mailboxId; + if (mailboxId == null || state.status != MailListStatus.ready) { + await retry(); + return; + } + _listSeq++; + _dirty = false; + state = state.copyWith(refreshing: true); + await _openFirst(mailboxId, _listSeq, silent: false); + } + + /// 첫 쪽을 받는다 — 처음 여는 목록은 알림 없이(화면이 받지 못함을 그린다), 새로고침은 인터셉터가 알린다 + Future _openFirst( + String mailboxId, + int seq, { + required bool silent, + }) async { + final Ref ref = this.ref; + final bool keepVisible = !silent; + try { + final MailPage page = await _repository.listMessages( + mailboxId: mailboxId, + silent: silent, + ); + if (!ref.mounted || seq != _listSeq) return; + _noteServerTime(page); + _localGeneration++; + state = state + .withItems(_withPendingFlags(page.items), pageInfo: page.pageInfo) + .copyWith( + refreshing: false, + loadingMore: false, + loadMoreFailed: false, + ); + _afterItemsChanged(); + } on DioException catch (error) { + if (!ref.mounted || seq != _listSeq) return; + if (keepVisible) { + // 보이던 목록은 그대로 둔다 — 알림은 인터셉터가 띄웠다. 새로고침이 세대를 올려 그 전에 떠난 이어 받기는 이미 버렸다 + state = state.copyWith(refreshing: false, loadingMore: false); + return; + } + state = state.failed(toApiException(error)); + } + } + + /// 다음 쪽 — 끝 근처에 닿으면 화면이 부른다(끝의 [다시 시도]도). `pageInfo.nextCursor` 를 해석하지 않고 그대로 보낸다 + Future loadMore() async { + final Ref ref = this.ref; + if (!ref.mounted) return; + final MailListState current = state; + final String? cursor = current.pageInfo?.nextCursor; + final String? mailboxId = current.mailboxId; + if (!current.hasMore || + cursor == null || + mailboxId == null || + current.loadingMore || + current.refreshing || + current.status != MailListStatus.ready) { + return; + } + final int seq = _listSeq; + state = current.copyWith(loadingMore: true, loadMoreFailed: false); + try { + final MailPage page = await _repository.listMessages( + mailboxId: mailboxId, + cursor: cursor, + ); + if (!ref.mounted || seq != _listSeq) return; + _noteServerTime(page); + // 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다 + final Set known = { + for (final MessageSummary message in state.items) message.id, + }; + final List added = [ + for (final MessageSummary message in _withPendingFlags(page.items)) + if (!known.contains(message.id)) message, + ]; + // 새로 붙은 것 없이 같은 커서가 돌아왔다 — 끝 표시가 같은 커서로 끝없이 부르지 않게 [다시 시도]로 둔다 + // (모두 겹친 쪽이라도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 부른다) + final bool stalled = added.isEmpty && page.pageInfo.nextCursor == cursor; + // 이어 받은 뒤의 맞추기 응답은 이 쪽을 모른다 — 그 전에 떠난 맞추기는 버리고 한 번 더 맞춘다 + _localGeneration++; + state = state + .withItems([ + ...state.items, + ...added, + ], pageInfo: page.pageInfo) + .copyWith(loadingMore: false, loadMoreFailed: stalled); + _afterItemsChanged(); + } on DioException { + if (!ref.mounted || seq != _listSeq) return; + // 알림은 인터셉터가 띄웠다 — 끝에 [다시 시도]를 둔다(저절로 다시 하지 않는다) + state = state.copyWith(loadingMore: false, loadMoreFailed: true); + } + } + + void _noteServerTime(MailPage page) { + final DateTime? serverTime = page.serverTime; + if (serverTime != null) _serverOffset = serverTime.difference(_clock()); + } + + // ── 새 메일 확인으로 맞추기 ─────────────────────────── + + void _onMailChange(MailChange change) { + if (identical(change.source, this)) return; + if (_visible) { + unawaited(_sync()); + } else { + _dirty = true; + } + } + + /// 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 — 고른 메일 · 스크롤 위치는 그대로(계약 2절 "새 메일 확인"). + /// 한 번에 하나 — 도는 동안 또 부르면 끝난 뒤 한 번 더 맞추고, 돌려주는 Future 는 그것까지 끝나야 끝난다. + /// 실패는 알리지 않는다(다음 확인에서 다시 맞춘다) + Future _sync() { + final Future? running = _syncRun; + if (running != null) { + _syncAgain = true; + return running; + } + final Future run = _runSyncs(ref); + _syncRun = run; + return run; + } + + Future _runSyncs(Ref ref) async { + try { + bool retriedForLocalChange = false; + do { + _syncAgain = false; + final bool staleByLocalChange = await _syncOnce(ref); + // 묻는 사이 이 목록이 바뀌었으면 그 응답은 버리고 한 번 더(한 번만) + if (staleByLocalChange && !retriedForLocalChange) { + retriedForLocalChange = true; + _syncAgain = true; + } + } while (_syncAgain && ref.mounted); + } finally { + // 그사이 다시 만들어졌으면 새 build 가 이미 비웠다 + if (ref.mounted) _syncRun = null; + } + } + + /// 맞추기 한 번 — 묻는 사이 이 목록이 바뀌어 응답을 버렸으면 참 + Future _syncOnce(Ref ref) async { + if (!ref.mounted) return false; + final MailListState current = state; + final String? mailboxId = current.mailboxId; + if (current.status != MailListStatus.ready || mailboxId == null) { + return false; + } + final int seq = _listSeq; + final int generation = _localGeneration; + final int limit = math.min( + kMailListSyncMax, + math.max(kMailPageSize, current.items.length), + ); + try { + final MailPage page = await _repository.listMessages( + mailboxId: mailboxId, + limit: limit, + silent: true, + ); + if (!ref.mounted || + seq != _listSeq || + state.status != MailListStatus.ready) { + return false; + } + if (generation != _localGeneration) return true; + _noteServerTime(page); + final MailListMerge merged = mergeRefreshedList( + current: state.items, + currentPageInfo: state.pageInfo, + fetched: _withPendingFlags(page.items), + fetchedPageInfo: page.pageInfo, + ); + state = state.withItems(merged.items, pageInfo: merged.pageInfo); + _afterItemsChanged(); + } on DioException { + // 알리지 않는다 — 다음 확인에서 다시 맞춘다 + } + return false; + } + + /// 목록이 바뀐 뒤 — 아직 보내지 않은 메일이 풀릴 때를 다시 잡는다 + void _afterItemsChanged() => _scheduleHeldCheck(); + + // ── 아직 보내지 않은 메일이 풀릴 때 ───────────────────── + + String _heldKey(MessageSummary message, PendingSend pending) => + '${message.id}\n${pending.sendAt.toIso8601String()}'; + + /// 서버 시계로 지금 — 받은 응답의 `Date` 로 잰 차이를 기기 시계에 더한다(없으면 기기 시계) + DateTime _serverNow() => _clock().add(_serverOffset); + + /// 가장 이른 보낼 시각 + 2초에 목록을 다시 본다 — 시각이 지난 채 남으면 5초마다 3번 더(웹 `usePendingSendRefresh`). + /// 보낼 시각은 서버 시계로 잰다(기기 시계가 틀려도 제때 본다) + void _scheduleHeldCheck() { + _cancelHeldTimer(); + if (!ref.mounted || state.status != MailListStatus.ready) return; + final DateTime now = _serverNow(); + DateTime? next; + for (final MessageSummary message in state.items) { + final PendingSend? pending = message.pendingSend; + if (pending == null) continue; + final DateTime due = pending.sendAt.add(kHeldReleaseGrace); + final DateTime at; + if (due.isAfter(now)) { + at = due; + } else { + final int rechecks = _heldRechecks[_heldKey(message, pending)] ?? 0; + if (rechecks > kHeldReleaseMaxRechecks) continue; + at = now.add(kHeldReleaseRecheck); + } + if (next == null || at.isBefore(next)) next = at; + } + if (next == null) return; + _heldTimer = Timer(next.difference(now), _onHeldDue); + } + + void _onHeldDue() { + _heldTimer = null; + final Ref ref = this.ref; + if (!ref.mounted) return; + final DateTime now = _serverNow(); + for (final MessageSummary message in state.items) { + final PendingSend? pending = message.pendingSend; + if (pending == null || + pending.sendAt.add(kHeldReleaseGrace).isAfter(now)) { + continue; + } + final String key = _heldKey(message, pending); + _heldRechecks[key] = (_heldRechecks[key] ?? 0) + 1; + } + if (!_visible) { + _dirty = true; + return; + } + // 맞추기가 실패해도(연결 끊김) 다음 차례를 다시 잡는다 — 횟수 한도는 그대로 + unawaited( + _sync().whenComplete(() { + if (ref.mounted) _scheduleHeldCheck(); + }), + ); + } + + void _cancelHeldTimer() { + _heldTimer?.cancel(); + _heldTimer = null; + } + + // ── 고르기 ───────────────────────────────────────── + + /// 하나 고르기 · 풀기 — 동그란 사진 · 길게 누르기 · 고르는 중의 줄 누르기. + /// 고를 수 없는 메일(아직 보내지 않은 메일 · 목록에 없음)이면 아무것도 하지 않고 거짓 + bool toggleSelection(String id) { + if (!ref.mounted) return false; + final MessageSummary? message = _find(id); + if (message == null || message.isHeld) return false; + final Set next = {...state.selectedIds}; + if (!next.remove(id)) next.add(id); + state = state.copyWith(selectedIds: Set.unmodifiable(next)); + return true; + } + + /// 고르기를 끝낸다 — ✕ · 뒤로 가기 · 목록이 가려짐 + void clearSelection() { + if (!ref.mounted || state.selectedIds.isEmpty) return; + state = state.copyWith(selectedIds: const {}); + } + + MessageSummary? _find(String id) { + for (final MessageSummary message in state.items) { + if (message.id == id) return message; + } + return null; + } + + // ── 여러 통 처리 ───────────────────────────────────── + + /// [읽음 표시] — 고른 것이 모두 읽음이면 안 읽음으로 + Future toggleSelectedRead() async { + if (!ref.mounted) return; + await _patchSelectedFlags(( + isRead: !state.allSelectedRead, + isFlagged: null, + )); + } + + /// [중요 표시] — 고른 것이 모두 중요면 해제 + Future toggleSelectedFlag() async { + if (!ref.mounted) return; + await _patchSelectedFlags(( + isRead: null, + isFlagged: !state.allSelectedFlagged, + )); + } + + /// 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고 고르기를 끝낸다. + /// 서버가 바꾸지 못한 메일 · 보내지 못한 메일만 바꾼 칸을 되돌리고(그사이 보내는 중인 다른 표시는 다시 입힌다), + /// 통째로 실패했으면 고른 것도 되살린다. 알림은 띄우지 않는다(목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다) + Future _patchSelectedFlags(_FlagChange change) async { + final Ref ref = this.ref; + if (!ref.mounted) return; + final List targets = state.selectedMessages; + if (targets.isEmpty) return; + final int seq = _listSeq; + final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier); + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + final TokenStore tokens = ref.read(tokenStoreProvider); + final int session = tokens.sessionGeneration; + final List ids = [ + for (final MessageSummary message in targets) message.id, + ]; + final Map before = { + for (final MessageSummary message in targets) message.id: message, + }; + + _adjustUnread(mailboxes, targets, change.isRead, direction: 1); + _localGeneration++; + state = state + .withItems(_applyFlags(state.items, ids.toSet(), change)) + .copyWith(selectedIds: const {}); + final int owner = ++_flagRequestSeq; + _beginPending(ids, change, owner); + + final MailRepository repository = _repository; + final BulkResult result = await sendInChunks(ids, kMailBulkMaxIds, ( + List chunk, + ) async { + final MessagesPatched patched = await repository.updateMessages( + chunk, + MessageChanges(isRead: change.isRead, isFlagged: change.isFlagged), + ); + return (done: patched.updatedIds, skipped: patched.failedIds); + }); + + final bool alive = ref.mounted; + final bool sameSession = tokens.sessionGeneration == session; + final Set revertIds = { + ...result.skippedIds, + ...result.unsentIds, + }; + final List reverted = [ + for (final String id in revertIds) ?before[id], + ]; + final bool fresh = alive && seq == _listSeq; + if (alive) { + _endPending(ids, change, owner); + _localGeneration++; + } + if (fresh) { + if (reverted.isNotEmpty) { + state = state.withItems( + _withPendingFlags([ + for (final MessageSummary message in state.items) + if (revertIds.contains(message.id) && before[message.id] != null) + message.copyWith( + isRead: change.isRead == null + ? null + : before[message.id]!.isRead, + isFlagged: change.isFlagged == null + ? null + : before[message.id]!.isFlagged, + ) + else + message, + ]), + ); + } + // 통째로 실패했다 — 다시 해 볼 수 있게 고른 것을 되살린다(목록에서 빠졌거나 고를 수 없게 된 메일은 뺀다) + if (result.doneIds.isEmpty && result.skippedIds.isEmpty) { + final List items = state.items; + state = state + .copyWith( + selectedIds: Set.unmodifiable({ + ...state.selectedIds, + ...ids, + }), + ) + .withItems(items); + } + } else if (alive) { + // 그사이 처음부터 다시 받았다 — 그 목록은 이 일이 서버에 닿기 전의 값일 수 있어 서버 기준으로 다시 맞춘다 + unawaited(_sync()); + } + // 끝난 세션의 결과는 새 세션에 보이지 않는다 + if (!sameSession) return; + if (reverted.isNotEmpty) { + _adjustUnread(mailboxes, reverted, change.isRead, direction: -1); + } + // 바꾼 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다) + if (result.doneIds.isNotEmpty) { + watcher.markChanged(source: fresh ? this : null); + } else if (change.isRead != null) { + mailboxes.syncCounts(); + } + } + + /// 안 읽은 개수를 먼저 바꾼다 — [direction] 1 은 바꿀 때, -1 은 되돌릴 때 + void _adjustUnread( + MailboxesNotifier mailboxes, + List messages, + bool? isRead, { + required int direction, + }) { + if (isRead == null) return; + for (final MessageSummary message in messages) { + if (message.isRead == isRead) continue; + mailboxes.adjustUnread(message.mailboxIds, (isRead ? -1 : 1) * direction); + } + } + + List _applyFlags( + List items, + Set ids, + _FlagChange change, + ) => [ + for (final MessageSummary message in items) + ids.contains(message.id) + ? message.copyWith(isRead: change.isRead, isFlagged: change.isFlagged) + : message, + ]; + + void _beginPending(List ids, _FlagChange change, int owner) { + final bool? isRead = change.isRead; + final bool? isFlagged = change.isFlagged; + for (final String id in ids) { + if (isRead != null) _pendingRead[id] = (value: isRead, owner: owner); + if (isFlagged != null) { + _pendingFlagged[id] = (value: isFlagged, owner: owner); + } + } + } + + /// 요청 [owner] 가 끝났다 — 그 요청이 아직 주인인 칸만 내린다(뒤에 보낸 요청의 표시는 남긴다) + void _endPending(List ids, _FlagChange change, int owner) { + for (final String id in ids) { + if (change.isRead != null && _pendingRead[id]?.owner == owner) { + _pendingRead.remove(id); + } + if (change.isFlagged != null && _pendingFlagged[id]?.owner == owner) { + _pendingFlagged.remove(id); + } + } + } + + /// 보내는 중인 읽음 · 중요 표시를 받은 목록 위에 덮어쓴다 + List _withPendingFlags(List items) { + if (_pendingRead.isEmpty && _pendingFlagged.isEmpty) return items; + return [ + for (final MessageSummary message in items) + if ((_pendingRead[message.id], _pendingFlagged[message.id]) case ( + final _PendingFlag? read, + final _PendingFlag? flagged, + ) when read != null || flagged != null) + message.copyWith(isRead: read?.value, isFlagged: flagged?.value) + else + message, + ]; + } + + /// [삭제] (휴지통 밖) — 휴지통으로 옮긴다. 휴지통을 모르면 메일함 목록을 받아 보고, 그래도 모르면 알린다(옮기지 않는다) + Future moveSelectedToTrash() async { + final Ref ref = this.ref; + if (!ref.mounted || state.busy || state.selectedMessages.isEmpty) return; + final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier); + Mailbox? trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash); + if (trash == null) { + await mailboxes.reload(); + if (!ref.mounted) return; + trash = ref.read(mailboxesProvider).byRole(MailboxRole.trash); + } + if (trash == null) { + ref + .read(errorNotifierProvider) + .show( + ref.read(mailboxesProvider).failure ?? + ApiException.fromCode('BIZ_001'), + ); + return; + } + await moveSelected(trash); + } + + /// 고른 메일을 [target] 으로 옮긴다 — 응답의 `updatedIds` 만 목록에서 빼고 알림에 [되돌리기]를 단다. + /// 그대로 둔 메일 · 보내지 못한 메일이 있으면(503 은 일부만 바뀌었을 수 있다) 목록을 서버 기준으로 다시 맞춘다 + Future moveSelected(Mailbox target) async { + final Ref ref = this.ref; + if (!ref.mounted || state.busy) return; + final List targets = state.selectedMessages; + if (targets.isEmpty) return; + final int seq = _listSeq; + final MailRepository repository = _repository; + final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier); + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + final MailToastNotifier toast = ref.read(mailToastProvider.notifier); + final TokenStore tokens = ref.read(tokenStoreProvider); + final int session = tokens.sessionGeneration; + final String? scope = state.mailboxId; + final Map origins = { + for (final MessageSummary message in targets) + if (_originOf(message, scope, target.id) case final String origin + when origin != target.id) + message.id: origin, + }; + + state = state.copyWith(busy: true); + final BulkResult result = await sendInChunks( + [for (final MessageSummary message in targets) message.id], + kMailBulkMaxIds, + (List chunk) async { + final MessagesPatched patched = await repository.updateMessages( + chunk, + MessageChanges(mailboxId: target.id), + ); + return (done: patched.updatedIds, skipped: patched.failedIds); + }, + ); + + final bool alive = ref.mounted; + final bool fresh = alive && seq == _listSeq; + _settleRemoval(result, alive: alive, fresh: fresh); + // 끝난 세션의 결과는 새 세션에 알리지 않는다 — [되돌리기]도 없다 + if (tokens.sessionGeneration != session) return; + // 옮긴 것이 있으면 다른 목록 · 메일함 개수에 알린다(메일함 개수는 그 소식으로 맞춘다) + if (result.doneIds.isNotEmpty) { + watcher.markChanged(source: fresh ? this : null); + } else { + mailboxes.syncCounts(); + } + + final int done = result.doneIds.length; + final int skipped = result.skippedIds.length; + if (done == 0 && skipped == 0) return; + final String text = MailBulkTexts.moved( + done, + target.name, + skipped: skipped, + ); + final List groups = groupByOrigin(result.doneIds, origins); + if (groups.isEmpty) { + toast.notify(text); + return; + } + toast.offer( + text, + actionLabel: MailBulkTexts.undo, + onAction: () => _restoreMoved( + groups, + repository: repository, + mailboxes: mailboxes, + watcher: watcher, + toast: toast, + tokens: tokens, + session: session, + ), + ); + } + + /// 되돌리기에 쓸 원래 메일함 — 이 목록의 메일함, 그 메일이 거기 없으면 옮길 곳이 아닌 첫 메일함 + String? _originOf(MessageSummary message, String? scope, String targetId) { + if (scope != null && message.mailboxIds.contains(scope)) return scope; + for (final String id in message.mailboxIds) { + if (id != targetId) return id; + } + return message.mailboxIds.isEmpty ? null : message.mailboxIds.first; + } + + /// 휴지통에서 영구 삭제 — 확인 창에서 [영구 삭제]를 누른 뒤에만 부른다. 지운 메일(`deletedIds`)만 목록에서 뺀다 + Future purgeSelected() async { + final Ref ref = this.ref; + if (!ref.mounted || state.busy) return; + final List targets = state.selectedMessages; + if (targets.isEmpty) return; + final int seq = _listSeq; + final MailRepository repository = _repository; + final MailboxesNotifier mailboxes = ref.read(mailboxesProvider.notifier); + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + final MailToastNotifier toast = ref.read(mailToastProvider.notifier); + final TokenStore tokens = ref.read(tokenStoreProvider); + final int session = tokens.sessionGeneration; + + state = state.copyWith(busy: true); + final BulkResult result = await sendInChunks( + [for (final MessageSummary message in targets) message.id], + kMailBulkMaxIds, + (List chunk) async { + final MessageDeletions deleted = await repository.deleteMessages(chunk); + return (done: deleted.deletedIds, skipped: deleted.skippedIds); + }, + ); + + final bool alive = ref.mounted; + final bool fresh = alive && seq == _listSeq; + _settleRemoval(result, alive: alive, fresh: fresh); + if (tokens.sessionGeneration != session) return; + if (result.doneIds.isNotEmpty) { + watcher.markChanged(source: fresh ? this : null); + } else { + mailboxes.syncCounts(); + } + final int done = result.doneIds.length; + final int skipped = result.skippedIds.length; + if (done > 0 || skipped > 0) { + toast.notify(MailBulkTexts.purged(done, skipped: skipped)); + } + } + + /// 옮기기 · 영구 삭제가 끝났다 — 이 목록이 그대로면 처리한 메일만 빼고(하나라도 했으면 고르기를 끝낸다), + /// 그대로 둔 메일이 있거나 그사이 처음부터 다시 받았으면 서버 기준으로 다시 맞춘다 + void _settleRemoval( + BulkResult result, { + required bool alive, + required bool fresh, + }) { + if (!alive) return; + if (!fresh) { + state = state.copyWith(busy: false); + unawaited(_sync()); + return; + } + _removeFromList(result.doneIds.toSet()); + state = state.copyWith( + busy: false, + selectedIds: result.doneIds.isEmpty ? null : const {}, + ); + if (result.hasLeftovers) unawaited(_sync()); + } + + /// 목록에서 뺀다 — 전체 수도 그만큼 줄인다 + void _removeFromList(Set ids) { + if (ids.isEmpty) return; + final List kept = [ + for (final MessageSummary message in state.items) + if (!ids.contains(message.id)) message, + ]; + final int removed = state.items.length - kept.length; + final MailPageInfo? info = state.pageInfo; + final int? total = info?.total; + _localGeneration++; + state = state.withItems( + kept, + pageInfo: info != null && total != null && removed > 0 + ? info.copyWith(total: math.max(0, total - removed)) + : info, + ); + _afterItemsChanged(); + } +} + +/// [되돌리기] — 옮긴 메일을 원래 메일함별로 다시 옮긴 뒤 개수와 목록을 서버 기준으로 맞춘다. +/// +/// 알림이 목록보다 오래 남을 수 있어(메일함을 바꿈) 목록 없이 돈다. 만든 세션에서만 움직인다 — +/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에는 아무것도 하지 않는다(메일 id 는 계정마다 따로 매겨져 겹칠 수 있다). +Future _restoreMoved( + List origins, { + required MailRepository repository, + required MailboxesNotifier mailboxes, + required MailStateWatcher watcher, + required MailToastNotifier toast, + required TokenStore tokens, + required int session, +}) async { + if (tokens.sessionGeneration != session) return; + final List done = []; + final List skipped = []; + for (final BulkOrigin group in origins) { + final BulkResult result = await sendInChunks(group.ids, kMailBulkMaxIds, ( + List chunk, + ) async { + final MessagesPatched patched = await repository.updateMessages( + chunk, + MessageChanges(mailboxId: group.mailboxId), + ); + return (done: patched.updatedIds, skipped: patched.failedIds); + }); + done.addAll(result.doneIds); + skipped.addAll(result.skippedIds); + if (result.unsentIds.isNotEmpty) break; + if (tokens.sessionGeneration != session) return; + } + if (tokens.sessionGeneration != session) return; + // 모든 목록 · 메일함 개수가 서버 기준으로 맞춘다 + if (done.isNotEmpty) { + watcher.markChanged(); + } else { + mailboxes.syncCounts(); + } + if (done.isNotEmpty || skipped.isNotEmpty) { + toast.notify(MailBulkTexts.restored(done.length, skipped: skipped.length)); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_list_state.dart b/flutter/lib/features/mail/presentation/providers/mail_list_state.dart new file mode 100644 index 0000000..3290f4e --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_list_state.dart @@ -0,0 +1,140 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:flutter/foundation.dart'; + +/// 목록의 상태 +enum MailListStatus { + /// 첫 쪽을 받는 중 + loading, + + /// 받았다(비었을 수도 있다) + ready, + + /// 첫 쪽을 받지 못했다 — 받지 못함 + [다시 시도] + failed, +} + +/// 메일함 하나의 목록 · 고른 메일 +@immutable +class MailListState { + const MailListState({ + this.status = MailListStatus.loading, + this.mailboxId, + this.items = const [], + this.pageInfo, + this.error, + this.refreshing = false, + this.loadingMore = false, + this.loadMoreFailed = false, + this.selectedIds = const {}, + this.busy = false, + }); + + final MailListStatus status; + + /// 보는 메일함 — 받은편지함 주소(`/mail`)는 메일함 목록을 받기 전까지 null + final String? mailboxId; + final List items; + final MailPageInfo? pageInfo; + + /// 첫 쪽을 받지 못한 까닭 (코드 · 추적 ID) + final ApiException? error; + + /// 당겨서 새로고침하는 중 + final bool refreshing; + + /// 다음 쪽을 받는 중 + final bool loadingMore; + + /// 다음 쪽을 받지 못했다 — 끝에 [다시 시도] + final bool loadMoreFailed; + + /// 고른 메일 id — 아직 보내지 않은 메일(붙잡힌 발송)은 들지 않는다 + final Set selectedIds; + + /// 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다(읽음 · 중요 표시는 막지 않는다) + final bool busy; + + /// 다음 쪽이 있다 + bool get hasMore => pageInfo?.hasMore ?? false; + + /// 여러 통 고르기 중 + bool get selecting => selectedIds.isNotEmpty; + + /// 고른 메일 — 목록 차례 그대로 + List get selectedMessages => [ + for (final MessageSummary message in items) + if (selectedIds.contains(message.id) && !message.isHeld) message, + ]; + + int get selectedCount => selectedMessages.length; + + /// 고른 메일이 모두 읽음 — [읽음 표시]가 [안 읽음 표시]가 된다 + bool get allSelectedRead { + final List selected = selectedMessages; + return selected.isNotEmpty && + selected.every((MessageSummary message) => message.isRead); + } + + /// 고른 메일이 모두 중요 — [중요 표시]가 [중요 해제]가 된다 + bool get allSelectedFlagged { + final List selected = selectedMessages; + return selected.isNotEmpty && + selected.every((MessageSummary message) => message.isFlagged); + } + + MailListState copyWith({ + MailListStatus? status, + String? mailboxId, + bool? refreshing, + bool? loadingMore, + bool? loadMoreFailed, + Set? selectedIds, + bool? busy, + }) => MailListState( + status: status ?? this.status, + mailboxId: mailboxId ?? this.mailboxId, + items: items, + pageInfo: pageInfo, + error: error, + refreshing: refreshing ?? this.refreshing, + loadingMore: loadingMore ?? this.loadingMore, + loadMoreFailed: loadMoreFailed ?? this.loadMoreFailed, + selectedIds: selectedIds ?? this.selectedIds, + busy: busy ?? this.busy, + ); + + /// 받은 목록으로 바꾼다 — 목록에서 빠졌거나 고를 수 없게 된 메일은 고른 것에서도 뺀다 + MailListState withItems( + List items, { + MailPageInfo? pageInfo, + MailListStatus status = MailListStatus.ready, + }) { + final Set selectable = { + for (final MessageSummary message in items) + if (!message.isHeld) message.id, + }; + final Set kept = selectedIds.where(selectable.contains).toSet(); + return MailListState( + status: status, + mailboxId: mailboxId, + items: List.unmodifiable(items), + pageInfo: pageInfo ?? this.pageInfo, + refreshing: refreshing, + loadingMore: loadingMore, + loadMoreFailed: loadMoreFailed, + selectedIds: kept.length == selectedIds.length + ? selectedIds + : Set.unmodifiable(kept), + busy: busy, + ); + } + + /// 첫 쪽을 받지 못했다 + MailListState failed(ApiException error) => MailListState( + status: MailListStatus.failed, + mailboxId: mailboxId, + error: error, + ); +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_providers.dart b/flutter/lib/features/mail/presentation/providers/mail_providers.dart new file mode 100644 index 0000000..d37d490 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_providers.dart @@ -0,0 +1,22 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다 +final Provider mailRepositoryProvider = + Provider( + (Ref ref) => MailRepository(ref.watch(dioProvider)), + ); + +/// 메일을 보는 직원 — 로그인했고 제한이 없을 때 그 주소, 그 밖에는 null. +/// +/// 메일 모듈의 상태(메일함 · 목록 · 알림 · 새 메일 확인)는 이것을 듣고 바뀌면 처음부터 다시 만든다 — +/// 로그아웃 · 세션 끝 · 다른 직원 로그인 뒤에 앞 직원의 메일이 남지 않게. 로그아웃은 늘 null 을 거치므로 같은 직원이 다시 들어와도 새로 시작한다. +final Provider mailAccountProvider = Provider((Ref ref) { + final SessionState? session = ref.watch(sessionProvider).value; + return session is SignedIn && session.info.restriction == null + ? session.info.email + : null; +}); diff --git a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart new file mode 100644 index 0000000..c4413ce --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 새 메일 확인 간격 — 화면이 보이는 동안 이만큼마다 바뀐 것이 있는지 묻는다(웹 `MAIL_STATE_POLL_INTERVAL_MS` 와 같다) +const Duration kMailStatePollInterval = Duration(seconds: 60); + +/// 앱이 다시 앞으로 오면 마지막으로 물은 지 이만큼 지났을 때 바로 한 번 묻는다(웹 `MAIL_STATE_RECHECK_GAP_MS`) +const Duration kMailStateRecheckGap = Duration(seconds: 10); + +/// 메일이 바뀌었다는 소식 — 목록 · 메일함 개수가 듣는다 +@immutable +class MailChange { + const MailChange(this.seq, {this.source}); + + /// 소식마다 하나씩 오른다 — 처음부터 다시 만들면 0 + final int seq; + + /// 이 앱의 목록이 스스로 바꾼 것이면 그 목록(그 목록은 이미 맞췄다) — 서버에서 알아챈 바뀜 · 되돌리기는 null + final Object? source; + + @override + bool operator ==(Object other) => + other is MailChange && + other.seq == seq && + identical(other.source, source); + + @override + int get hashCode => Object.hash(seq, identityHashCode(source)); +} + +/// 새 메일 확인 — 메일 목록이 보이는 동안만 돈다. 세션이 바뀌면 기준을 버린다. +final NotifierProvider mailStateWatcherProvider = + NotifierProvider.autoDispose( + MailStateWatcher.new, + ); + +/// 새 메일 확인 (계약 2절 "새 메일 확인" · 웹 3단계 "새 메일" — `useMailStatePolling` 과 같은 결) +/// +/// - 메일 목록 화면이 보이고([setViewing]) 앱이 앞에 있는 동안만 1분마다 `GET /api/mail-state` 를 묻는다. 한 번에 하나씩. +/// - 다시 보이게 되면(앱이 앞으로 옴 · 다른 탭이나 전체 화면에서 돌아옴) 마지막으로 물은 지 10초가 지났으면 바로 한 번, 아니면 원래 차례에. +/// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다. +/// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다. +/// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다). +/// - 푸시(새 메일 알림)는 다음 차례다. +class MailStateWatcher extends Notifier { + final Set _viewers = {}; + Timer? _timer; + bool _foreground = true; + bool _signedIn = false; + bool _asking = false; + DateTime? _lastAskedAt; + String? _lastState; + + Clock get _clock => ref.read(clockProvider); + + @override + MailChange build() { + _signedIn = ref.watch(mailAccountProvider) != null; + // 세션이 바뀌면 기준 · 물은 때를 버린다(화면의 등록은 그대로 — 화면은 남아 있다) + _timer?.cancel(); + _timer = null; + _asking = false; + _lastAskedAt = null; + _lastState = null; + _foreground = ref.read(appForegroundProvider); + ref.listen(appForegroundProvider, (bool? previous, bool next) { + final bool before = _active; + _foreground = next; + if (before != _active) _onActiveChanged(); + }); + ref.onDispose(() { + _timer?.cancel(); + _timer = null; + }); + // 다시 만들어진 뒤에도 물을 때이면(보이는 목록이 그대로 있다) 새 차례를 잡는다 — build 안에서는 묻지 않는다 + final Ref built = ref; + scheduleMicrotask(() { + if (built.mounted && _active && _timer == null && !_asking) { + _onActiveChanged(); + } + }); + return const MailChange(0); + } + + /// 지금 물을 때인가 — 로그인했고, 보이는 목록이 있고, 앱이 앞에 있다 + bool get _active => _signedIn && _foreground && _viewers.isNotEmpty; + + /// 목록 하나가 보이게 · 안 보이게 됐다 — [viewer] 는 그 목록 + void setViewing(Object viewer, {required bool viewing}) { + if (!ref.mounted) return; + final bool before = _active; + if (viewing) { + _viewers.add(viewer); + } else { + _viewers.remove(viewer); + } + if (before != _active) _onActiveChanged(); + } + + /// 이 앱이 메일을 바꿨다 — 다른 목록 · 메일함 개수가 맞추게 알린다. [source] 는 이미 맞춘 목록(없으면 모든 목록) + void markChanged({Object? source}) { + if (!ref.mounted) return; + state = MailChange(state.seq + 1, source: source); + } + + void _onActiveChanged() { + if (!_active) { + _timer?.cancel(); + _timer = null; + return; + } + // 묻는 중이면 그 물음이 끝난 뒤 다음 차례를 잡는다 + if (_asking) return; + final DateTime? last = _lastAskedAt; + final Duration? elapsed = last == null ? null : _clock().difference(last); + if (elapsed == null || + elapsed.isNegative || + elapsed >= kMailStateRecheckGap) { + unawaited(_ask()); + } else { + _schedule(kMailStatePollInterval - elapsed); + } + } + + void _schedule(Duration delay) { + _timer?.cancel(); + _timer = null; + if (!_active) return; + _timer = Timer(delay.isNegative ? Duration.zero : delay, () { + _timer = null; + unawaited(_ask()); + }); + } + + Future _ask() async { + if (_asking || !_active) return; + final Ref ref = this.ref; + _asking = true; + _timer?.cancel(); + _timer = null; + _lastAskedAt = _clock(); + try { + final String value = await ref.read(mailRepositoryProvider).mailState(); + if (!ref.mounted) return; + final String? previous = _lastState; + _lastState = value; + if (previous != null && previous != value) { + state = MailChange(state.seq + 1); + } + } on DioException { + // 알리지 않는다 — 다음 차례에 다시 묻는다 + } finally { + // 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다 + if (ref.mounted) { + _asking = false; + _schedule(kMailStatePollInterval); + } + } + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart new file mode 100644 index 0000000..7c9e9ac --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 단추 없는 알림이 닫히기까지 — 웹 `TOAST_DURATION_MS.success` 와 같다 +const Duration kMailToastDuration = Duration(seconds: 5); + +/// 단추가 있는 알림([되돌리기])이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히(웹 `TOAST_ACTION_DURATION_MS`) +const Duration kMailToastActionDuration = Duration(seconds: 10); + +/// 메일 화면 아래의 알림 줄 — 단추가 있는 알림만 둔다(보드 `App1MailList` 의 "메일 2통을 휴지통으로 옮겼습니다. [되돌리기]") +@immutable +class MailToast { + const MailToast({ + required this.id, + required this.text, + required this.actionLabel, + }); + + /// 알림마다 다르다 — 단추 · 닫기가 그 알림에만 듣게 + final int id; + final String text; + final String actionLabel; +} + +/// 단추 없는 알림을 띄우는 곳 — 앱의 전역 알림 자리(`ScaffoldMessenger` 스낵바, 테마의 알림 모양). +/// +/// 탭 안 화면의 Scaffold 가 받으므로 [메일 쓰기] · 단추 있는 알림 줄 위에 뜬다 — 둘이 겹치지 않고, [되돌리기]를 바꿔 끼우지 않는다. +/// 시험은 덮어써 적어 둔다. +final Provider mailNoticeSinkProvider = + Provider((Ref ref) { + final GlobalKey key = ref.watch( + scaffoldMessengerKeyProvider, + ); + return (String text) { + final ScaffoldMessengerState? messenger = key.currentState; + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(text), duration: kMailToastDuration), + ); + }; + }); + +/// 메일 화면의 알림 — 메일 화면이 있는 동안만 둔다. 세션이 바뀌면 닫는다(되돌리기는 만든 세션에서만 움직인다). +final NotifierProvider mailToastProvider = + NotifierProvider.autoDispose( + MailToastNotifier.new, + ); + +/// 메일 화면의 알림. +/// +/// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다. +/// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결). +/// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 — +/// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함). +/// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다. +/// - 단추는 한 번만 돈다(두 번 눌러도 — 웹 `takeAction`). +class MailToastNotifier extends Notifier { + Timer? _timer; + Future Function()? _action; + int _seq = 0; + + /// 화면 낭독기를 쓰는 중 — 단추 알림을 저절로 닫지 않는다 + bool _holding = false; + + @override + MailToast? build() { + ref.watch(mailAccountProvider); + _cancelTimer(); + _action = null; + _holding = ref.read(accessibleNavigationProvider); + ref.listen(accessibleNavigationProvider, (bool? previous, bool next) { + _holding = next; + _onHoldChanged(); + }); + ref.onDispose(_cancelTimer); + return null; + } + + /// 단추 없는 알림 — 전역 알림 자리에 띄운다 + void notify(String text) { + if (!ref.mounted) return; + ref.read(mailNoticeSinkProvider)(text); + } + + /// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다 + void offer( + String text, { + required String actionLabel, + required Future Function() onAction, + }) { + if (!ref.mounted) return; + final int id = ++_seq; + _cancelTimer(); + _action = onAction; + state = MailToast(id: id, text: text, actionLabel: actionLabel); + if (!_holding) _startTimer(id); + } + + /// 닫는다 — [id] 의 알림이 아직 보일 때만 + void dismiss(int id) { + if (!ref.mounted || state?.id != id) return; + _cancelTimer(); + _action = null; + state = null; + } + + /// 단추 — 알림을 닫고 한 번만 한다 + void runAction(int id) { + if (!ref.mounted || state?.id != id) return; + final Future Function()? action = _action; + _action = null; + dismiss(id); + if (action != null) unawaited(action()); + } + + void _onHoldChanged() { + final MailToast? toast = state; + if (toast == null) return; + if (_holding) { + _cancelTimer(); + } else if (_timer == null) { + _startTimer(toast.id); + } + } + + void _startTimer(int id) { + _cancelTimer(); + _timer = Timer(kMailToastActionDuration, () { + _timer = null; + dismiss(id); + }); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart b/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart new file mode 100644 index 0000000..b08d6b8 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mailboxes_provider.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 메일함 목록의 상태 +enum MailboxesStatus { loading, ready, failed } + +/// 메일함 목록 — 서랍 · 목록 제목 · 안 읽은 수 · 옮길 곳이 함께 쓴다 +@immutable +class MailboxesState { + const MailboxesState({ + this.status = MailboxesStatus.loading, + this.items = const [], + this.error, + }); + + final MailboxesStatus status; + final List items; + + /// 처음 받지 못했을 때의 실패 — 이미 받은 목록이 있으면 다시 받기 실패는 여기 두지 않는다 + final ApiException? error; + + /// 처음부터 받지 못했다 — 받은편지함 목록도 열 수 없다 + ApiException? get failure => status == MailboxesStatus.failed ? error : null; + + /// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 첫 메일함. 받기 전이면 null + String? get defaultMailboxId => + status == MailboxesStatus.ready ? defaultMailboxOf(items)?.id : null; + + /// 서랍 차례 + List get ordered => orderMailboxes(items); + + Mailbox? byId(String? id) { + if (id == null) return null; + for (final Mailbox mailbox in items) { + if (mailbox.id == id) return mailbox; + } + return null; + } + + Mailbox? byRole(MailboxRole role) => findMailboxByRole(items, role); +} + +/// 메일함 목록 — 메일 화면이 있는 동안만 둔다(autoDispose). 세션이 바뀌면 처음부터. +final NotifierProvider mailboxesProvider = + NotifierProvider.autoDispose( + MailboxesNotifier.new, + ); + +/// 메일함 목록 — `GET /api/mailboxes` (계약 2절) +/// +/// - 처음 받지 못하면 목록 화면이 받지 못함 + [다시 시도]를 그린다. 이미 받은 뒤의 다시 받기는 실패해도 조용히 둔다(웹과 같다). +/// - 새 메일 확인이 바뀜을 알리면 개수를 다시 맞춘다. 읽음 표시는 개수를 먼저 바꾸고 다음 조회가 서버 기준으로 맞춘다. +/// - 늦게 온 응답은 버린다(나중에 보낸 요청의 응답만 쓴다). +class MailboxesNotifier extends Notifier { + int _seq = 0; + + @override + MailboxesState build() { + final String? account = ref.watch(mailAccountProvider); + _seq++; + // 새 메일 확인 · 다른 목록의 옮기기가 바뀜을 알리면 개수를 맞춘다 (처음부터 다시 만들 때의 0 은 바뀜이 아니다) + ref.listen(mailStateWatcherProvider, ( + MailChange? previous, + MailChange next, + ) { + if (next.seq > (previous?.seq ?? 0)) syncCounts(); + }); + if (account == null) return const MailboxesState(); + unawaited(_load(_seq)); + return const MailboxesState(); + } + + /// 다시 받는다 — 처음 받지 못했던 목록은 불러오는 중으로 바꾼다([다시 시도]) + Future reload() { + if (!ref.mounted) return Future.value(); + _seq++; + if (state.status != MailboxesStatus.ready) { + state = const MailboxesState(); + } + return _load(_seq); + } + + /// 보이는 화면은 그대로 둔 채 개수를 서버 기준으로 다시 맞춘다 — 옮기기 · 삭제 · 새로고침 · 새 메일 확인 뒤 + void syncCounts() { + if (!ref.mounted) return; + _seq++; + unawaited(_load(_seq)); + } + + /// 안 읽은 개수를 화면에서 먼저 바꾼다 — 서버 기준은 다음 조회가 맞춘다 + void adjustUnread(List mailboxIds, int delta) { + if (!ref.mounted || delta == 0) return; + state = MailboxesState( + status: state.status, + error: state.error, + items: [ + for (final Mailbox mailbox in state.items) + mailboxIds.contains(mailbox.id) + ? mailbox.copyWith( + unreadCount: (mailbox.unreadCount + delta) + .clamp(0, math.max(0, mailbox.totalCount)) + .toInt(), + ) + : mailbox, + ], + ); + } + + Future _load(int seq) async { + final Ref ref = this.ref; + try { + final List items = await ref + .read(mailRepositoryProvider) + .listMailboxes(); + if (!ref.mounted || seq != _seq) return; + state = MailboxesState( + status: MailboxesStatus.ready, + items: List.unmodifiable(items), + ); + } on DioException catch (error) { + if (!ref.mounted || seq != _seq) return; + // 이미 목록이 보이면 조용히 둔다 + if (state.status == MailboxesStatus.ready) return; + state = MailboxesState( + status: MailboxesStatus.failed, + error: toApiException(error), + ); + } + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart new file mode 100644 index 0000000..32704d3 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart @@ -0,0 +1,195 @@ +import 'dart:math' as math; + +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// [메일 쓰기] 단추의 그림자 높이 — 보드의 `0 6px 16px` 그림자 +const double _composeElevation = 6; + +/// 알림 줄의 그림자 높이 — 보드의 `0 10px 28px` 그림자 +const double _toastElevation = 8; + +/// 화면 아래의 [메일 쓰기] · 알림 줄 자리 — Scaffold 의 넓은 단추 자리(`floatingActionButton`)에 둔다. +/// +/// - 보드 `App1MailList`: [메일 쓰기]는 탭 막대 위 16(아래에서 88), 알림이 뜨면 알림(아래 12 · 좌우 12) 위로 12만큼 함께 올라간다. +/// - 넓은 단추 자리에 두는 까닭: Scaffold 는 전역 알림(API 실패 · 단추 없는 알림 스낵바)을 이 자리 **위에** 띄운다 — 단추 · 알림 줄을 가리지 않는다. +/// - 고르는 중에는 [메일 쓰기]를 숨긴다(보드 — 탭 막대도 숨는다). 알림 줄은 남는다. +/// - 탭 막대가 숨으면(고르는 중 · 자판) 화면 아래가 시스템 내비게이션 자리까지 내려온다 — 안전 영역(아래 · 좌우, [safeInsets])만큼 띄운다. +/// Scaffold 는 이 자리에 안전 영역을 넘기지 않으므로(네 방향 모두 지운다) Scaffold 밖에서 읽어 넘겨받는다. +/// 탭 막대가 있을 때는 틀이 아래 여백을 이미 뺐다(0). +class MailComposeArea extends ConsumerWidget { + const MailComposeArea({ + required this.showCompose, + required this.safeInsets, + required this.onCompose, + super.key, + }); + + final bool showCompose; + + /// 화면의 안전 영역(아래 · 좌우) — Scaffold 를 감싼 화면의 `MediaQuery.paddingOf` + final EdgeInsets safeInsets; + final VoidCallback onCompose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final MailToast? toast = ref.watch(mailToastProvider); + if (!showCompose && toast == null) return const SizedBox.shrink(); + return Padding( + padding: EdgeInsets.fromLTRB( + safeInsets.left, + 0, + safeInsets.right, + safeInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showCompose) + Padding( + padding: EdgeInsets.only( + right: AppSpacing.s16, + bottom: toast == null ? AppSpacing.s16 : AppSpacing.s12, + ), + child: _ComposeButton(onPressed: onCompose), + ), + if (toast != null) + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + 0, + AppSpacing.s12, + AppSpacing.s12, + ), + child: MailToastCard( + toast: toast, + onAction: () => + ref.read(mailToastProvider.notifier).runAction(toast.id), + ), + ), + ), + ], + ), + ); + } +} + +class _ComposeButton extends StatelessWidget { + const _ComposeButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + return FloatingActionButton.extended( + // 같은 화면에 목록이 둘(받은편지함 위에 다른 메일함) 쌓일 수 있다 — 화면 사이를 나는 그림(Hero)은 쓰지 않는다 + heroTag: null, + onPressed: onPressed, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + elevation: _composeElevation, + highlightElevation: _composeElevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xxl), + ), + extendedPadding: const EdgeInsetsDirectional.only( + start: AppSpacing.s16, + end: AppSpacing.s20, + ), + extendedIconLabelSpacing: AppSpacing.s10, + extendedTextStyle: theme.textTheme.labelLarge, + icon: const Icon(Icons.edit_outlined, size: AppSize.iconToolbar), + label: const Text(MailListTexts.compose), + ); + } +} + +/// 알림 줄 — 짙은 청록 바탕 · 흰 글 · 오른쪽 [되돌리기] (보드 `App1MailList` · 앱 테마의 알림 색과 같다) +class MailToastCard extends StatelessWidget { + const MailToastCard({required this.toast, required this.onAction, super.key}); + + final MailToast toast; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + liveRegion: true, + child: Material( + color: scheme.inverseSurface, + elevation: _toastElevation, + shadowColor: scheme.shadow, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppSize.toastMinHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s2, + AppSpacing.s4, + AppSpacing.s2, + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.s10, + ), + child: Text( + toast.text, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onPrimary, + ), + ), + ), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: extra.onBrandLink, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + onPressed: onAction, + child: Text(toast.actionLabel), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// [메일 쓰기] · 알림 줄 자리를 화면 오른쪽 아래에 붙인다 — 여백(안전 영역 포함)은 자리 안([MailComposeArea])이 정한다. +/// (Flutter 의 기본 자리는 단추 하나를 위한 여백 · 안전 영역 셈을 하므로 쓰지 않는다) +/// +/// 알림 줄이 있으면 자리가 화면 폭을 다 쓰므로 왼쪽 끝에 붙는다. Scaffold 는 전역 알림을 이 자리 위에 띄운다. +class MailComposeAreaLocation extends FloatingActionButtonLocation { + const MailComposeAreaLocation(); + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + final Size area = scaffoldGeometry.floatingActionButtonSize; + final double x = math.max( + 0, + scaffoldGeometry.scaffoldSize.width - area.width, + ); + return Offset(x, scaffoldGeometry.contentBottom - area.height); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart new file mode 100644 index 0000000..741a0b3 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_app_bar.dart @@ -0,0 +1,213 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:flutter/material.dart'; + +/// 목록의 위 막대 — 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기 (보드 `App1MailList` 「받은편지함」, 높이 64) +class MailListAppBar extends StatelessWidget implements PreferredSizeWidget { + const MailListAppBar({ + required this.title, + required this.onOpenDrawer, + required this.onSearch, + this.subtitle, + super.key, + }); + + final String title; + + /// 제목 아래 작은 글 — "안 읽은 메일 3통". 보낸편지함 · 임시보관함 · 휴지통은 없다 + final String? subtitle; + final VoidCallback onOpenDrawer; + final VoidCallback onSearch; + + @override + Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? sub = subtitle; + return AppBar( + toolbarHeight: AppSize.appBarHeight, + leadingWidth: AppSize.touchMin + AppSpacing.s8, + titleSpacing: AppSpacing.s4, + shape: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + leading: IconButton( + icon: const Icon(Icons.menu, size: AppSize.iconLarge), + tooltip: MailListTexts.openDrawer, + color: extra.textStrong, + onPressed: onOpenDrawer, + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + header: true, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + height: AppTypography.lineHeightTight, + ), + ), + ), + if (sub != null) + Text( + sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: extra.textMuted, + ), + ), + ], + ), + actionsPadding: const EdgeInsets.only(right: AppSpacing.s4), + actions: [ + IconButton( + icon: const Icon(Icons.search, size: AppSize.iconLarge), + tooltip: MailListTexts.search, + color: extra.textStrong, + onPressed: onSearch, + ), + ], + ); + } +} + +/// 여러 통 고르기 막대 — 선택 해제 · "N통 선택됨" · 읽음 표시 · 중요 표시 · 이동 · 삭제 +/// (보드 `App1MailList` 「길게 눌러 고르기」 — 바탕 옅은 청록, 화면 낭독기 이름 "고른 메일") +class MailSelectionBar extends StatelessWidget implements PreferredSizeWidget { + const MailSelectionBar({ + required this.count, + required this.allRead, + required this.allFlagged, + required this.inTrash, + required this.busy, + required this.onClear, + required this.onToggleRead, + required this.onToggleFlag, + required this.onMove, + required this.onDelete, + super.key, + }); + + final int count; + + /// 고른 것이 모두 읽음 — [안 읽음 표시] + final bool allRead; + + /// 고른 것이 모두 중요 — [중요 해제] + final bool allFlagged; + + /// 휴지통 — [삭제]가 빨간 [영구 삭제] + final bool inTrash; + + /// 옮기기 · 지우기를 하는 중 — 이동 · 삭제를 다시 누르지 못한다 + final bool busy; + final VoidCallback onClear; + final VoidCallback onToggleRead; + final VoidCallback onToggleFlag; + final VoidCallback onMove; + final VoidCallback onDelete; + + @override + Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + final Color toolColor = scheme.onPrimaryContainer; + return Semantics( + container: true, + label: MailBulkTexts.toolbar, + explicitChildNodes: true, + child: AppBar( + toolbarHeight: AppSize.appBarHeight, + backgroundColor: scheme.secondaryContainer, + leadingWidth: AppSize.touchMin + AppSpacing.s8, + titleSpacing: AppSpacing.s4, + shape: Border( + bottom: BorderSide( + color: extra.brandTintBorder, + width: AppSize.borderWidth, + ), + ), + leading: IconButton( + icon: const Icon(Icons.close, size: AppSize.iconLarge), + tooltip: MailBulkTexts.clear, + color: toolColor, + onPressed: onClear, + ), + title: Semantics( + liveRegion: true, + child: Text( + MailBulkTexts.selected(count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + color: toolColor, + ), + ), + ), + actionsPadding: const EdgeInsets.only(right: AppSpacing.s4), + actions: [ + IconButton( + icon: Icon( + allRead + ? Icons.mark_email_unread_outlined + : Icons.drafts_outlined, + size: AppSize.iconToolbar, + ), + tooltip: allRead + ? MailBulkTexts.markUnread + : MailBulkTexts.markRead, + color: toolColor, + onPressed: onToggleRead, + ), + IconButton( + icon: Icon( + allFlagged ? Icons.star : Icons.star_border, + size: AppSize.iconToolbar, + ), + tooltip: allFlagged ? MailBulkTexts.unflag : MailBulkTexts.flag, + color: toolColor, + onPressed: onToggleFlag, + ), + IconButton( + icon: const Icon( + Icons.drive_file_move_outlined, + size: AppSize.iconToolbar, + ), + tooltip: MailBulkTexts.move, + color: toolColor, + onPressed: busy ? null : onMove, + ), + IconButton( + icon: Icon( + inTrash ? Icons.delete_forever_outlined : Icons.delete_outline, + size: AppSize.iconToolbar, + ), + tooltip: inTrash ? MailBulkTexts.purge : MailBulkTexts.delete, + color: inTrash ? scheme.error : toolColor, + onPressed: busy ? null : onDelete, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart new file mode 100644 index 0000000..24e2cc9 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -0,0 +1,366 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 문의 코드로 보이는 추적 ID 앞자리 수 — 웹 `TRACE_CODE_LENGTH` 와 같다 +const int kTraceCodeLength = 8; + +/// 처음 불러오는 동안의 뼈대 줄 수 — 휴대폰 한 화면쯤 +const int _skeletonRows = 8; + +/// 빈 메일함 · 받지 못함의 아래 여백 — 안내가 화면 가운데보다 조금 위에 오게(보드의 `padding-bottom: 120px`) +const double _stateBottomSpace = 120; + +/// 뼈대 줄의 막대 폭 — 보낸 사람 · 제목(미리 보기 막대는 줄 폭을 다 쓴다) +const double _skeletonWhoWidth = 0.4; +const double _skeletonSubjectWidth = 0.8; + +/// 끝에서 도는 표시의 선 두께 — 작은 원(24)에 맞춘다 +const double _footerSpinnerStroke = 2.5; + +/// 빈 메일함 — 아이콘 원 · "휴지통이 비어 있습니다" · 메일함마다 안내(휴지통 · 스팸메일함은 30일 안내가 붙는다) +/// (보드 `App1MailList` 「빈 메일함 (휴지통)」) +class MailListEmpty extends StatelessWidget { + const MailListEmpty({ + required this.mailboxName, + required this.role, + super.key, + }); + + final String mailboxName; + final MailboxRole? role; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return _StateLayout( + icon: mailboxIconOf(role), + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailListTexts.emptyTitle(mailboxName), + children: [ + KeepAllText( + MailListTexts.emptyHint(role), + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ); + } +} + +/// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다) +/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다) +class MailListError extends StatelessWidget { + const MailListError({ + required this.error, + required this.onRetry, + this.onLeave, + super.key, + }); + + final ApiException error; + final VoidCallback onRetry; + + /// 없는 메일함(`RES_001`)일 때 갈 곳 — 없으면 늘 [다시 시도] + final VoidCallback? onLeave; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final VoidCallback? leave = onLeave; + final bool notFound = error.code == 'RES_001' && leave != null; + final String? trace = traceCodeOf(error.traceId); + return _StateLayout( + icon: Icons.error_outline, + iconColor: theme.colorScheme.error, + iconBackground: theme.colorScheme.errorContainer, + title: error.message, + children: [ + if (trace != null) + Text( + MailListTexts.traceCode(trace), + style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted), + ), + TextButton( + onPressed: notFound ? leave : onRetry, + child: Text(notFound ? MailListTexts.toInbox : MailListTexts.retry), + ), + ], + ); + } +} + +/// 추적 ID → 화면의 문의 코드(앞 8자리). 없으면 null +String? traceCodeOf(String? traceId) { + final String? value = traceId?.trim(); + if (value == null || value.isEmpty) return null; + return value.length > kTraceCodeLength + ? value.substring(0, kTraceCodeLength) + : value; +} + +class _StateLayout extends StatelessWidget { + const _StateLayout({ + required this.icon, + required this.iconColor, + required this.iconBackground, + required this.title, + required this.children, + }); + + final IconData icon; + final Color iconColor; + final Color iconBackground; + final String title; + final List children; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s40, + 0, + AppSpacing.s40, + _stateBottomSpace, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Container( + width: AppSize.stateIcon, + height: AppSize.stateIcon, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: iconBackground, + ), + child: Icon(icon, size: AppSize.stateIconGlyph, color: iconColor), + ), + ), + const SizedBox(height: AppSpacing.s16), + // 여러 줄(긴 사전 문구)이어도 가운데에 — 글 자체를 가운데 맞춘다 + DefaultTextStyle.merge( + textAlign: TextAlign.center, + child: Semantics( + header: true, + child: KeepAllText( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.s8), + for (final Widget child in children) + DefaultTextStyle.merge(textAlign: TextAlign.center, child: child), + ], + ), + ); + } +} + +/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중") +class MailListSkeleton extends StatelessWidget { + const MailListSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest; + return Semantics( + label: MailListTexts.loading, + liveRegion: true, + child: ExcludeSemantics( + child: Column( + children: [ + for (int index = 0; index < _skeletonRows; index++) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + AppSpacing.s14, + AppSpacing.s16, + AppSpacing.s14, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: AppSize.avatar, + height: AppSize.avatar, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: bar, + ), + ), + const SizedBox(width: AppSpacing.s10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: _skeletonWhoWidth, + child: _SkeletonBar(color: bar), + ), + const SizedBox(height: AppSpacing.s8), + FractionallySizedBox( + widthFactor: _skeletonSubjectWidth, + child: _SkeletonBar(color: bar), + ), + const SizedBox(height: AppSpacing.s8), + _SkeletonBar(color: bar), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _SkeletonBar extends StatelessWidget { + const _SkeletonBar({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + height: AppSpacing.s10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(AppRadius.xs), + ), + ); + } +} + +/// 휴지통 · 스팸메일함 목록 맨 위의 30일 줄 — 회색 바탕 · 시계 아이콘(웹 13단계 — 늘 보인다, 닫기 없음) +class MailRetentionNotice extends StatelessWidget { + const MailRetentionNotice(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + border: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: AppSpacing.s8, + ), + child: Row( + children: [ + Icon( + Icons.schedule, + size: AppSize.iconTiny, + color: extra.textMuted, + ), + const SizedBox(width: AppSpacing.s8), + Expanded( + child: KeepAllText( + text, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 목록 끝 — 다음 쪽을 받는 동안 도는 표시, 받지 못했으면 [다시 시도]. +/// 화면에 들어오면(목록이 끝 근처를 그리면) 그린 뒤 [onReached] 를 부르고, 끝 표시가 남아 있는 동안 다시 그릴 때마다 또 부른다 +/// (받은 쪽이 모두 이미 있던 메일이라 줄이 늘지 않음 · 새로고침이 앞 요청을 버림). 받는 중 · 더 없음은 부르는 쪽이 거른다. +/// 받지 못한 동안은 부르지 않는다(저절로 다시 하지 않는다 — 같은 커서가 되풀이돼도 [다시 시도]로 멈춘다) +class MailListFooter extends StatefulWidget { + const MailListFooter({ + required this.loading, + required this.failed, + required this.onReached, + required this.onRetry, + super.key, + }); + + final bool loading; + final bool failed; + final VoidCallback onReached; + final VoidCallback onRetry; + + @override + State createState() => _MailListFooterState(); +} + +class _MailListFooterState extends State { + @override + void initState() { + super.initState(); + _reachAfterFrame(); + } + + @override + void didUpdateWidget(MailListFooter oldWidget) { + super.didUpdateWidget(oldWidget); + // 받는 중이던 표시가 끝난 순간을 기다리지 않는다 — 응답이 한 프레임 안에 오면 그 순간을 보지 못한다 + _reachAfterFrame(); + } + + void _reachAfterFrame() { + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + if (mounted && !widget.loading && !widget.failed) widget.onReached(); + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: AppSize.touchMin + AppSpacing.s16, + child: Center( + child: widget.failed + ? TextButton( + onPressed: widget.onRetry, + child: const Text(MailListTexts.retry), + ) + : Semantics( + label: MailListTexts.loadingMore, + child: const SizedBox.square( + dimension: AppSize.iconLarge, + child: CircularProgressIndicator( + strokeWidth: _footerSpinnerStroke, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart b/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart new file mode 100644 index 0000000..f06a9db --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_move_sheet.dart @@ -0,0 +1,83 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; +import 'package:flutter/material.dart'; + +/// 옮길 메일함을 고르는 시트 — 아래에서 올라온다(보드에 없다 — 서랍과 같은 줄 모양). +/// +/// [targets] 는 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함이다(`moveTargetsOf` — 웹과 같다). 고르면 그 메일함, 닫으면 null. +Future showMailboxPickerSheet( + BuildContext context, { + required List targets, +}) { + final ThemeData theme = Theme.of(context); + return showModalBottomSheet( + context: context, + useRootNavigator: true, + showDragHandle: true, + backgroundColor: theme.colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (BuildContext sheetContext) => _MailboxPickerSheet(targets), + ); +} + +class _MailboxPickerSheet extends StatelessWidget { + const _MailboxPickerSheet(this.targets); + + final List targets; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s28, + 0, + AppSpacing.s28, + AppSpacing.s8, + ), + child: Semantics( + header: true, + child: Text( + MailBulkTexts.moveSheetTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s12, + AppSpacing.s16, + ), + children: [ + for (final MailboxNode node in targets) + MailboxTile( + node: node, + selected: false, + onTap: () => Navigator.of(context).pop(node.mailbox), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart b/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart new file mode 100644 index 0000000..5cbc239 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mailbox_drawer.dart @@ -0,0 +1,321 @@ +import 'dart:math' as math; + +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_icons.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 서랍이 미끄러져 들어오는 시간 +const Duration _drawerDuration = Duration(milliseconds: 250); + +/// 하위 메일함 들여쓰기는 3단계까지 (그 아래는 같은 깊이 — 웹과 같다) +const int _maxIndentDepth = 3; + +/// 서랍의 그림자 높이 — 보드의 `0 14px 36px` 그림자에 가깝게(Material 모달 서랍) +const double _drawerElevation = 16; + +/// 메일함 서랍을 연다 — 탭 막대 위까지 화면 전체를 덮는다(앱 보드 `App1MailList` 「메일함 서랍」 · 앱 디자인 README). +/// +/// 메일함을 고르면 그 메일함을 돌려준다(부르는 쪽이 그 목록으로 간다). 바깥을 누르거나 뒤로 가기면 null. +Future showMailboxDrawer( + BuildContext context, { + required String? currentMailboxId, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MailDrawerTexts.close, + barrierColor: Theme.of(context).colorScheme.scrim, + transitionDuration: _drawerDuration, + pageBuilder: + ( + BuildContext dialogContext, + Animation animation, + Animation secondaryAnimation, + ) => Align( + alignment: AlignmentDirectional.centerStart, + child: MailboxDrawer(currentMailboxId: currentMailboxId), + ), + transitionBuilder: + ( + BuildContext dialogContext, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => SlideTransition( + position: Tween(begin: const Offset(-1, 0), end: Offset.zero) + .animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); +} + +/// 메일함 서랍 — 기본 메일함 · 내 메일함(하위는 들여 쓴다). 안 읽은 수는 회색 숫자(뱃지 없음) +class MailboxDrawer extends ConsumerWidget { + const MailboxDrawer({required this.currentMailboxId, super.key}); + + /// 지금 보는 메일함 — 청록 바탕 + final String? currentMailboxId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final MailboxesState mailboxes = ref.watch(mailboxesProvider); + final double width = math.min( + AppSize.drawerWidth, + MediaQuery.sizeOf(context).width - AppSize.drawerMinGap, + ); + return Semantics( + container: true, + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: MailDrawerTexts.title, + child: Material( + color: theme.colorScheme.surface, + elevation: _drawerElevation, + shadowColor: theme.colorScheme.shadow, + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(AppRadius.xxl), + ), + child: SizedBox( + width: width, + height: double.infinity, + child: SafeArea( + right: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s28, + AppSpacing.s24, + AppSpacing.s28, + AppSpacing.s8, + ), + child: Semantics( + header: true, + child: Text( + MailDrawerTexts.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + ), + Expanded( + child: _DrawerBody( + mailboxes: mailboxes, + currentMailboxId: currentMailboxId, + onRetry: () => + ref.read(mailboxesProvider.notifier).reload(), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DrawerBody extends StatelessWidget { + const _DrawerBody({ + required this.mailboxes, + required this.currentMailboxId, + required this.onRetry, + }); + + final MailboxesState mailboxes; + final String? currentMailboxId; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final ApiException? failure = mailboxes.failure; + if (failure != null) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.s24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KeepAllText( + failure.message, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textMuted, + ), + ), + const SizedBox(height: AppSpacing.s8), + TextButton( + onPressed: onRetry, + child: const Text(MailListTexts.retry), + ), + ], + ), + ); + } + if (mailboxes.status != MailboxesStatus.ready) { + return const Center(child: CircularProgressIndicator()); + } + + final ({List system, List mine}) groups = + splitMailboxGroups(mailboxes.ordered); + return ListView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s12, + AppSpacing.s12, + ), + children: [ + for (final MailboxNode node in groups.system) + MailboxTile( + node: node, + selected: node.mailbox.id == currentMailboxId, + showUnread: true, + onTap: () => Navigator.of(context).pop(node.mailbox), + ), + if (groups.mine.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.s8, + horizontal: AppSpacing.s4, + ), + child: Divider(color: extra.borderSubtle), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s4, + AppSpacing.s16, + AppSpacing.s2, + ), + child: Semantics( + header: true, + child: Text( + MailDrawerTexts.myMailboxes, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textMuted, + ), + ), + ), + ), + for (final MailboxNode node in groups.mine) + MailboxTile( + node: node, + selected: node.mailbox.id == currentMailboxId, + showUnread: true, + onTap: () => Navigator.of(context).pop(node.mailbox), + ), + ], + ], + ); + } +} + +/// 메일함 한 줄 — 알약 모양(높이 48 이상) · 아이콘 · 이름 · 안 읽은 수(회색 숫자). 서랍 · 옮길 곳 시트가 함께 쓴다 +class MailboxTile extends StatelessWidget { + const MailboxTile({ + required this.node, + required this.selected, + required this.onTap, + this.showUnread = false, + super.key, + }); + + final MailboxNode node; + + /// 지금 보는 메일함 — 청록 바탕 · 굵게 + final bool selected; + + /// 안 읽은 수를 보인다 (서랍만) + final bool showUnread; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + final Mailbox mailbox = node.mailbox; + final Color foreground = selected + ? scheme.onPrimaryContainer + : extra.textStrong; + final int unread = mailbox.unreadCount; + final double indent = + math.min(node.depth, _maxIndentDepth) * AppSpacing.s16; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.s2), + child: Semantics( + selected: selected, + child: Material( + color: selected ? scheme.primaryContainer : Colors.transparent, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppSize.touchMin), + child: Padding( + padding: EdgeInsetsDirectional.only( + start: AppSpacing.s16 + indent, + end: AppSpacing.s16, + ), + child: Row( + children: [ + Icon( + mailboxIconOf(mailbox.role), + size: AppSize.iconMedium, + color: foreground, + ), + const SizedBox(width: AppSpacing.s14), + Expanded( + child: Text( + mailbox.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: selected + ? AppTypography.semiBold + : AppTypography.medium, + color: foreground, + ), + ), + ), + if (showUnread && unread > 0) + Text( + formatCount(unread), + semanticsLabel: MailDrawerTexts.unreadCount(unread), + style: theme.textTheme.bodySmall?.copyWith( + color: selected ? foreground : extra.textMuted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart b/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart new file mode 100644 index 0000000..8856c11 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mailbox_icons.dart @@ -0,0 +1,13 @@ +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:flutter/material.dart'; + +/// 메일함 아이콘 — 웹 `MAILBOX_ICONS`(lucide)에 맞춘 Material 아이콘 (서랍 · 옮길 곳 · 빈 메일함) +IconData mailboxIconOf(MailboxRole? role) => switch (role) { + MailboxRole.inbox => Icons.inbox_outlined, + MailboxRole.sent => Icons.send_outlined, + MailboxRole.drafts => Icons.description_outlined, + MailboxRole.junk => Icons.report_outlined, + MailboxRole.trash => Icons.delete_outline, + MailboxRole.archive => Icons.archive_outlined, + null => Icons.folder_outlined, +}; diff --git a/flutter/lib/features/mail/presentation/widgets/message_row.dart b/flutter/lib/features/mail/presentation/widgets/message_row.dart new file mode 100644 index 0000000..1c757af --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/message_row.dart @@ -0,0 +1,277 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:flutter/material.dart'; + +/// 목록 줄 하나 — 보드 `App1MailList` 의 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 한 줄. +/// +/// - 안 읽음은 굵은 글자로만 가른다(뱃지 없음 — 화면 낭독기는 앞에 "안 읽음"). +/// - 사진을 누르면 고르기 · 풀기, 줄을 길게 누르면 고르기. 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다). +/// - 아직 보내지 않은 메일은 고를 수 없다 — 사진 단추는 꺼진 것으로 읽히고, 눌러도 까닭만 알린다(부르는 쪽이). +class MessageRow extends StatelessWidget { + const MessageRow({ + required this.view, + required this.selected, + required this.onTap, + required this.onLongPress, + required this.onAvatarTap, + super.key, + }); + + final MailRowView view; + + /// 고른 줄 — 바탕이 옅은 청록이고 사진 자리에 체크 + final bool selected; + final VoidCallback onTap; + final VoidCallback onLongPress; + final VoidCallback onAvatarTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + selected: selected, + label: view.semanticsLabel, + child: Material( + color: selected ? extra.rowChecked : theme.colorScheme.surface, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHigh, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s8, + AppSpacing.s10, + AppSpacing.s16, + AppSpacing.s10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MailAvatarButton( + initial: view.initial, + selected: selected, + held: view.held, + label: view.selectLabel, + onTap: onAvatarTap, + ), + const SizedBox(width: AppSpacing.s6), + Expanded(child: ExcludeSemantics(child: _RowTexts(view))), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// 동그란 사진 — 누르는 곳 48 안의 40 원(머리글자 · 고르면 청록 원에 체크). +/// 화면 낭독기는 "고르기: 제목" 체크 칸으로 읽는다. 아직 보내지 않은 메일이면 꺼진 칸이고 설명에 까닭을 단다 +class MailAvatarButton extends StatelessWidget { + const MailAvatarButton({ + required this.initial, + required this.selected, + required this.held, + required this.label, + required this.onTap, + super.key, + }); + + final String? initial; + final bool selected; + final bool held; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + button: true, + checked: selected, + enabled: !held, + label: label, + hint: held ? MailListTexts.heldNotSelectable : null, + onTap: onTap, + child: InkResponse( + onTap: onTap, + excludeFromSemantics: true, + radius: AppSize.touchMin / 2, + customBorder: const CircleBorder(), + child: SizedBox.square( + dimension: AppSize.touchMin, + child: Center( + child: ExcludeSemantics( + child: _AvatarFace(initial: initial, selected: selected), + ), + ), + ), + ), + ); + } +} + +class _AvatarFace extends StatelessWidget { + const _AvatarFace({required this.initial, required this.selected}); + + final String? initial; + final bool selected; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String? letter = initial; + final Widget face; + if (selected) { + face = Icon( + Icons.check, + size: AppSize.iconMedium, + color: scheme.onPrimary, + ); + } else if (letter != null) { + face = Text( + letter, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: AppTypography.semiBold, + color: scheme.onPrimaryContainer, + ), + ); + } else { + face = Icon( + Icons.person_outline, + size: AppSize.iconMedium, + color: scheme.onPrimaryContainer, + ); + } + return Container( + width: AppSize.avatar, + height: AppSize.avatar, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? scheme.primary : scheme.primaryContainer, + ), + child: face, + ); + } +} + +/// 줄의 글 세 줄 — 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 +class _RowTexts extends StatelessWidget { + const _RowTexts(this.view); + + final MailRowView view; + + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + final TextTheme text = theme.textTheme; + final bool unread = view.unread; + + final TextStyle? whoStyle = text.bodyMedium?.copyWith( + fontWeight: unread ? AppTypography.bold : AppTypography.medium, + color: view.whoPlaceholder ? extra.textMuted : scheme.onSurface, + ); + final TextStyle? timeStyle = text.labelSmall?.copyWith( + fontWeight: unread ? AppTypography.semiBold : AppTypography.regular, + color: view.timePending + ? scheme.primary + : (unread ? scheme.onSurface : extra.textMuted), + fontFeatures: _tabular, + ); + final TextStyle? subjectStyle = text.bodyMedium?.copyWith( + fontWeight: unread ? AppTypography.semiBold : AppTypography.regular, + color: view.subjectPlaceholder + ? extra.textMuted + : (unread ? scheme.onSurface : extra.textStrong), + ); + final TextStyle? previewStyle = text.bodySmall?.copyWith( + color: extra.textMuted, + ); + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + view.who, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: whoStyle, + ), + ), + const SizedBox(width: AppSpacing.s8), + Text(view.time, maxLines: 1, style: timeStyle), + ], + ), + Row( + children: [ + Expanded( + child: Text( + view.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: subjectStyle, + ), + ), + if (view.hasAttachment) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.s6), + child: Icon( + Icons.attach_file, + size: AppSize.iconTiny, + color: extra.textMuted, + semanticLabel: MailListTexts.attachment, + ), + ), + if (view.flagged) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.s6), + child: Icon( + Icons.star, + size: AppSize.iconTiny, + color: extra.star, + semanticLabel: MailListTexts.flagged, + ), + ), + ], + ), + Text( + view.preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: previewStyle, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/confirm_dialog.dart b/flutter/lib/shared/widgets/confirm_dialog.dart new file mode 100644 index 0000000..f55b3f8 --- /dev/null +++ b/flutter/lib/shared/widgets/confirm_dialog.dart @@ -0,0 +1,112 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 확인 창 — 앱 보드 `App1Shell` 의 [로그아웃] 확인과 같은 모양(모서리 16 · 제목 굵게 · 오른쪽 아래에 [취소] · 주 단추). +/// +/// 되돌릴 수 없는 일의 확인이라 **처음 초점은 [취소]** 다(웹 대화상자 규칙). [danger] 면 주 단추가 빨간 채움이다. +/// [확인]을 누르면 참, [취소] · 뒤로 가기 · 바깥 누르기는 거짓. +Future showConfirmDialog( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + required String cancelLabel, + bool danger = false, +}) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext dialogContext) => _ConfirmDialog( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + danger: danger, + ), + ); + return confirmed ?? false; +} + +class _ConfirmDialog extends StatelessWidget { + const _ConfirmDialog({ + required this.title, + required this.message, + required this.confirmLabel, + required this.cancelLabel, + required this.danger, + }); + + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + final bool danger; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final RoundedRectangleBorder buttonShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ); + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xxl), + ), + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + titlePadding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s24, + AppSpacing.s24, + 0, + ), + contentPadding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s12, + AppSpacing.s24, + 0, + ), + actionsPadding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s20, + AppSpacing.s16, + AppSpacing.s12, + ), + title: Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + content: KeepAllText( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + height: AppTypography.lineHeightRelaxed, + ), + ), + actions: [ + TextButton( + autofocus: true, + style: TextButton.styleFrom( + foregroundColor: scheme.onPrimaryContainer, + shape: buttonShape, + ), + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: danger ? scheme.error : scheme.primary, + foregroundColor: danger ? scheme.onError : scheme.onPrimary, + shape: buttonShape, + ), + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 4a84e83..9f1d5fa 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -12,6 +12,7 @@ import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -22,6 +23,11 @@ import 'support/fakes.dart'; import 'support/login_fakes.dart'; import 'support/provider_harness.dart'; +/// 메일 목록 화면 — [mailboxId] 가 null 이면 받은편지함(주소 `/mail`) +Finder mailList(String? mailboxId) => find.byWidgetPredicate( + (Widget widget) => widget is MailListPage && widget.mailboxId == mailboxId, +); + void main() { late ProviderHarness h; @@ -90,13 +96,7 @@ void main() { await pumpApp(tester); - expect( - find.descendant( - of: find.byType(ListView), - matching: find.text(MailTexts.home), - ), - findsOneWidget, - ); + expect(mailList(null), findsOneWidget); expect(find.byType(NavigationBar), findsOneWidget); }); @@ -154,7 +154,7 @@ void main() { final GoRouter router = await pumpSignedIn(tester); router.go(MailRoutes.mailbox('archive')); await tester.pumpAndSettle(); - expect(page(MailTexts.mailbox), findsOneWidget); + expect(mailList('archive'), findsOneWidget); await tester.tap(tab('설정')); await tester.pumpAndSettle(); @@ -162,7 +162,7 @@ void main() { await tester.tap(tab('메일')); await tester.pumpAndSettle(); - expect(page(MailTexts.mailbox), findsOneWidget); + expect(mailList('archive'), findsOneWidget); expect( router.routerDelegate.currentConfiguration.uri.path, MailRoutes.mailbox('archive'), @@ -171,7 +171,8 @@ void main() { // 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 await tester.tap(tab('메일')); await tester.pumpAndSettle(); - expect(page(MailTexts.home), findsOneWidget); + expect(mailList(null), findsOneWidget); + expect(mailList('archive'), findsNothing); }); testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async { @@ -204,7 +205,7 @@ void main() { MailRoutes.home, reason: location, ); - expect(page(MailTexts.home), findsOneWidget, reason: location); + expect(mailList(null), findsOneWidget, reason: location); } }); diff --git a/flutter/test/core/router/app_shell_test.dart b/flutter/test/core/router/app_shell_test.dart new file mode 100644 index 0000000..dadfbf7 --- /dev/null +++ b/flutter/test/core/router/app_shell_test.dart @@ -0,0 +1,114 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/app_router.dart'; +import 'package:arasystem_app/core/router/shell_bar.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/provider_harness.dart'; + +/// 휴대폰 화면 크기 (보드의 390 × 844) +const Size _phone = Size(390, 844); + +/// 로그인 뒤 화면의 틀 — 아래 탭 막대 (앱 보드 `App1Shell`) +void main() { + late ProviderHarness h; + late ProviderContainer container; + + setUp(() => h = ProviderHarness()); + + GoRouter router() => container.read(routerProvider); + + /// 되살린 세션으로 앱을 띄운다 — 첫 화면은 메일 탭(메일 API 는 없는 경로라 메일 화면은 받지 못함을 그린다) + Future pumpSignedIn(WidgetTester tester) async { + tester.view.physicalSize = _phone * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await h.prepare(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: h.clock.now), + }), + ); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + overrides: h.overrides, + child: const App(), + ), + ); + await tester.pumpAndSettle(); + container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ); + expect(find.byType(NavigationBar), findsOneWidget); + } + + testWidgets('탭 막대는 위 안전 영역(상태 표시줄)만큼 커지지 않는다 — 아래 안전 영역만 더한다', ( + WidgetTester tester, + ) async { + // 위 24 · 아래 16 (물리 픽셀은 3배) + tester.view.padding = const FakeViewPadding(top: 72, bottom: 48); + await pumpSignedIn(tester); + + final Rect bar = tester.getRect(find.byType(NavigationBar)); + expect(bar.height, moreOrLessEquals(AppSize.navigationBarHeight + 16)); + expect(bar.bottom, moreOrLessEquals(_phone.height)); + }); + + testWidgets('탭 막대의 화면 낭독기 이름은 "앱 메뉴"', (WidgetTester tester) async { + await pumpSignedIn(tester); + + expect(find.bySemanticsLabel(kAppMenuLabel), findsOneWidget); + }); + + testWidgets('자판이 열리면 탭 막대를 뺀다(화면이 자판 위까지 줄어든다)', (WidgetTester tester) async { + await pumpSignedIn(tester); + + tester.view.viewInsets = const FakeViewPadding(bottom: 900); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsNothing); + + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('화면이 숨긴 탭 막대는 그 모듈의 탭에서만 빠진다 — 다른 탭에서는 보인다', ( + WidgetTester tester, + ) async { + await pumpSignedIn(tester); + final ShellBarHiding hiding = container.read( + shellBarHidingProvider.notifier, + ); + + hiding.hide(AppModuleId.mail); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsNothing); + + router().go(SettingsRoutes.home); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + + router().go(MailRoutes.home); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsNothing); + + hiding.reveal(AppModuleId.mail); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); +} diff --git a/flutter/test/core/router/shell_bar_test.dart b/flutter/test/core/router/shell_bar_test.dart new file mode 100644 index 0000000..66a5c8b --- /dev/null +++ b/flutter/test/core/router/shell_bar_test.dart @@ -0,0 +1,30 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/shell_bar.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 아래 탭 막대 숨기기 — 화면마다 숨기고 돌려준다(모듈마다 센다) +void main() { + test('숨긴 화면이 모두 돌려줄 때까지 그 모듈의 탭에서 숨긴다 — 다른 모듈은 그대로', () { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + final ShellBarHiding hiding = container.read( + shellBarHidingProvider.notifier, + ); + Map state() => container.read(shellBarHidingProvider); + + hiding + ..hide(AppModuleId.mail) + ..hide(AppModuleId.mail); + expect(isShellBarHidden(state(), AppModuleId.mail), isTrue); + expect(isShellBarHidden(state(), AppModuleId.settings), isFalse); + + hiding.reveal(AppModuleId.mail); + expect(isShellBarHidden(state(), AppModuleId.mail), isTrue); + hiding.reveal(AppModuleId.mail); + expect(isShellBarHidden(state(), AppModuleId.mail), isFalse); + // 숨기지 않은 모듈을 돌려줘도 음수가 되지 않는다 + hiding.reveal(AppModuleId.settings); + expect(state(), isEmpty); + }); +} diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart index ea5118e..58eaddc 100644 --- a/flutter/test/features/auth/login_pages_test.dart +++ b/flutter/test/features/auth/login_pages_test.dart @@ -26,6 +26,11 @@ import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_ import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -1837,7 +1842,15 @@ bool _allowedProviderValue(Object? value) => value is AuthRepository || value is TwoFactorRepository || value is SupportContactRepository || - value is AuthenticatorLauncher; + value is AuthenticatorLauncher || + // 로그인을 마치면 메일 목록이 뜬다 — 메일함 · 목록 · 새 메일 확인 · 알림 · 탭 막대 숨김 · 앱이 앞에 있는가 + value is MailRepository || + value is MailboxesState || + value is MailListState || + value is MailChange || + value is MailToast || + value is Map || + value is bool; /// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 final class _ValueRecorder extends ProviderObserver { diff --git a/flutter/test/features/mail/mail_list_controller_test.dart b/flutter/test/features/mail/mail_list_controller_test.dart new file mode 100644 index 0000000..8df985e --- /dev/null +++ b/flutter/test/features/mail/mail_list_controller_test.dart @@ -0,0 +1,1035 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/provider_harness.dart'; + +/// 앱이 늘 앞에 있는 것으로 — 생명주기 시험은 화면 시험(`mail_list_page_test.dart`)이 한다 +class _Foreground extends AppForeground { + @override + bool build() => true; +} + +/// 화면 낭독기를 쓰지 않는 것으로 — 접근성 시험은 화면 시험이 한다 +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 메일 목록 컨트롤러 — 계약 2절 목록 · 여러 통 한꺼번에 · 새 메일 확인 (웹 list · selection · bulk 와 같은 규칙) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + + /// 단추 없는 알림(전역 알림 자리에 뜨는 것) + late List notices; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + notices = []; + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue(notices.add), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + MailListState stateOf([String? route]) => + container.read(mailListProvider(route)); + + MailListController controllerOf([String? route]) => + container.read(mailListProvider(route).notifier); + + /// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다 + Future open([String? route]) async { + final ProviderSubscription subscription = container.listen( + mailListProvider(route), + (MailListState? previous, MailListState next) {}, + ); + addTearDown(subscription.close); + // 알림 · 메일함 목록도 화면처럼 구독한다(autoDispose 가 사라지지 않게) + final ProviderSubscription toast = container.listen( + mailToastProvider, + (MailToast? previous, MailToast? next) {}, + ); + addTearDown(toast.close); + await pumpEventsUntil( + () => stateOf(route).status != MailListStatus.loading, + reason: '첫 쪽', + ); + return controllerOf(route); + } + + List idsOf([String? route]) => [ + for (final MessageSummary message in stateOf(route).items) message.id, + ]; + + MailToast? toast() => container.read(mailToastProvider); + + group('목록 · 이어 받기 · 새로고침', () { + test('받은편지함 주소는 메일함 목록을 받은 뒤 받은편지함의 첫 쪽(20통)을 받는다', () async { + mail.seed(kInboxId, 45); + + await open(); + + expect(stateOf().mailboxId, kInboxId); + expect(idsOf(), hasLength(20)); + expect(stateOf().hasMore, isTrue); + final RequestOptions request = mail.listRequests.single; + expect(request.queryParameters['mailboxId'], kInboxId); + expect(request.queryParameters['limit'], kMailPageSize); + expect(request.queryParameters.containsKey('cursor'), isFalse); + }); + + test('다음 쪽 — 커서를 그대로 보내고, 그사이 밀려 겹친 메일은 건너뛴다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + // 새 메일이 와서 경계가 한 통 밀렸다 — 다음 쪽의 첫 메일(m20)은 이미 있다 + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + await controller.loadMore(); + + expect(mail.listRequests.last.queryParameters['cursor'], 'c:20'); + // 다음 쪽은 m20 ~ m39 — 이미 있는 m20 을 건너뛴다 + expect(idsOf(), hasLength(39)); + expect(idsOf().where((String id) => id == 'm20'), hasLength(1)); + expect(idsOf().last, 'm39'); + }); + + test('다음 쪽을 받지 못하면 끝에 [다시 시도] — 알림은 인터셉터가 띄운다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.loadMore(); + + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().loadingMore, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().loadMoreFailed, isFalse); + }); + + test('처음 받지 못하면 알리지 않고 받지 못함 — [다시 시도]로 다시 받는다', () async { + mail.seed(kInboxId, 3); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(stateOf().error?.traceId, 'trace-MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + mail.listFailure = null; + await controller.retry(); + expect(stateOf().status, MailListStatus.ready); + expect(idsOf(), ['m1', 'm2', 'm3']); + }); + + test('메일함 목록을 받지 못하면 받은편지함도 받지 못함 — [다시 시도]는 메일함 목록부터', () async { + mail.seed(kInboxId, 2); + mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(mail.listRequests, isEmpty); + mail.mailboxesFailure = null; + await controller.retry(); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '받은편지함', + ); + expect(idsOf(), ['m1', 'm2']); + }); + + test('응답 모양이 계약과 다르면 SYS_001 로 받지 못함이다', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({'items': 'nope'}), + ); + + await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'SYS_001'); + }); + + test('당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받고, 고른 것은 남은 메일만 둔다', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m3'); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + final int boxesBefore = h.server + .received('GET', MailApiPaths.mailboxes) + .length; + + await controller.refresh(); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(stateOf().refreshing, isFalse); + expect( + h.server.received('GET', MailApiPaths.mailboxes).length, + boxesBefore + 1, + ); + }); + + test('늦은 응답 — 새로고침 뒤에 도착한 앞 쪽 응답은 버린다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + + await controller.refresh(); + gate.complete(); + await more; + + expect(idsOf(), hasLength(20), reason: '새로고침 전의 다음 쪽을 붙이지 않는다'); + expect(stateOf().loadingMore, isFalse); + }); + + test('메일함을 바꾸면(앞 목록이 사라지면) 앞 목록의 응답은 버린다', () async { + mail.seed(kClientsId, 2); + final Completer gate = Completer(); + mail.listGate = gate; + final ProviderSubscription subscription = container.listen( + mailListProvider(kClientsId), + (_, _) {}, + ); + await pumpEventsUntil( + () => mail.listRequests.isNotEmpty, + reason: '목록 요청', + ); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(kClientsId)), + reason: '앞 목록이 사라짐', + ); + gate.complete(); + await Future.delayed(Duration.zero); + + expect(container.exists(mailListProvider(kClientsId)), isFalse); + // 다시 열면 처음부터 받는다 + await open(kClientsId); + expect(idsOf(kClientsId), ['m1', 'm2']); + expect(mail.listRequests, hasLength(2)); + }); + + test('세션이 바뀌면 목록 · 고른 것 · 알림을 비우고, 세션이 없는 동안은 묻지 않는다', () async { + mail.seed(kInboxId, 3); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController controller = await open(); + await open(kClientsId); + controller.toggleSelection('m1'); + container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + final int requests = h.server.requests.length; + + container.read(_accountProvider.notifier).set(null); + + expect(stateOf().items, isEmpty); + expect(stateOf().selectedIds, isEmpty); + expect(stateOf(kClientsId).items, isEmpty); + expect(toast(), isNull); + expect(container.read(mailboxesProvider).items, isEmpty); + await Future.delayed(Duration.zero); + expect(h.server.requests, hasLength(requests)); + + // 다시 로그인하면 처음부터 받는다 + container.read(_accountProvider.notifier).set(kMailTestAccount); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '다시 받음', + ); + expect(idsOf(), ['m1', 'm2', 'm3']); + expect(stateOf().selectedIds, isEmpty); + }); + }); + + group('고르기', () { + test('하나씩 고르고 푼다 — 아직 보내지 않은 메일은 고를 수 없다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + messageJson( + 's2', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(kTestNow.add(const Duration(days: 1))), + ), + ]; + final MailListController controller = await open(kSentId); + + expect(controller.toggleSelection('s1'), isTrue); + expect(controller.toggleSelection('s2'), isFalse); + expect(controller.toggleSelection('없음'), isFalse); + expect(stateOf(kSentId).selectedIds, {'s1'}); + expect(stateOf(kSentId).selectedCount, 1); + controller.toggleSelection('s1'); + expect(stateOf(kSentId).selecting, isFalse); + controller + ..toggleSelection('s1') + ..clearSelection(); + expect(stateOf(kSentId).selecting, isFalse); + }); + + test('고른 뒤 붙잡히면(목록을 맞추니 보내기를 기다림) 고른 것에서 뺀다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + ]; + final MailListController controller = await open(kSentId); + controller + ..setVisible(true) + ..toggleSelection('s1'); + mail.messages[kSentId]!.first['pendingSend'] = pendingJson( + kTestNow.add(const Duration(minutes: 5)), + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil( + () => stateOf(kSentId).items.first.isHeld, + reason: '맞추기', + ); + + expect(stateOf(kSentId).selecting, isFalse); + }); + }); + + group('여러 통 처리', () { + test('읽음 표시 — 먼저 바꾸고 고르기를 끝내며, failedIds 만 되돌린다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2') + ..toggleSelection('m3'); + + final Future marking = controller.toggleSelectedRead(); + // 응답 전 — 화면이 먼저 바뀌고(안 읽은 수도) 고르기가 끝난다 + expect( + stateOf().items.every((MessageSummary message) => message.isRead), + isTrue, + ); + expect(stateOf().selecting, isFalse); + expect(container.read(mailboxesProvider).byId(kInboxId)!.unreadCount, 0); + await marking; + + expect(mail.patches.single, { + 'ids': ['m1', 'm2', 'm3'], + 'isRead': true, + }); + expect( + { + for (final MessageSummary message in stateOf().items) + message.id: message.isRead, + }, + {'m1': true, 'm2': false, 'm3': true}, + ); + expect(toast(), isNull, reason: '읽음 · 중요 표시는 알리지 않는다'); + expect(notices, isEmpty); + }); + + test('고른 것이 모두 읽음이면 안 읽음으로, 모두 중요면 해제로', () async { + mail.seed(kInboxId, 2, isRead: true); + for (final Map message in mail.messages[kInboxId]!) { + message['isFlagged'] = true; + } + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + expect(stateOf().allSelectedRead, isTrue); + expect(stateOf().allSelectedFlagged, isTrue); + + await controller.toggleSelectedRead(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + await controller.toggleSelectedFlag(); + + expect(mail.patches.first['isRead'], isFalse); + expect(mail.patches.last['isFlagged'], isFalse); + }); + + test('요청이 통째로 실패하면 읽음 표시를 되돌린다 — 알림은 인터셉터가', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.toggleSelectedRead(); + + expect(stateOf().items.first.isRead, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + // 다시 해 볼 수 있게 고른 것을 되살린다 + expect(stateOf().selectedIds, {'m1'}); + }); + + test('이동 — 응답의 updatedIds 만 빼고, 알림의 [되돌리기]는 원래 메일함으로 옮긴다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Mailbox clients = container + .read(mailboxesProvider) + .byId(kClientsId)!; + + await controller.moveSelected(clients); + + expect(mail.patches.single, { + 'ids': ['m1', 'm2'], + 'mailboxId': kClientsId, + }); + expect(idsOf(), ['m2', 'm3']); + expect(stateOf().selecting, isFalse); + expect(stateOf().busy, isFalse); + expect(toast()?.text, '메일 1통을 거래처로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.'); + expect(toast()?.actionLabel, MailBulkTexts.undo); + // 그대로 둔 메일이 있으면 서버 기준으로 다시 맞춘다 + await pumpEventsUntil(() => mail.listRequests.length >= 2, reason: '맞추기'); + + container.read(mailToastProvider.notifier).runAction(toast()!.id); + expect(toast(), isNull, reason: '단추를 누르면 알림 줄은 닫힌다'); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await pumpEventsUntil(() => notices.isNotEmpty, reason: '되돌린 알림'); + + expect(mail.patches.last, { + 'ids': ['m1'], + 'mailboxId': kInboxId, + }); + expect(notices, ['메일 1통을 되돌렸습니다.']); + await pumpEventsUntil(() => idsOf().contains('m1'), reason: '되돌린 목록'); + }); + + test('[되돌리기]는 한 번만 — 두 번 눌러도 한 번 보낸다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + final int id = toast()!.id; + + container.read(mailToastProvider.notifier) + ..runAction(id) + ..runAction(id); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(2)); + }); + + test('[되돌리기]는 만든 세션에서만 — 그사이 로그아웃했으면 보내지 않는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + await h.store.clear(); + container.read(mailToastProvider.notifier).runAction(toast()!.id); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(1)); + }); + + test('통째로 실패하면 목록 · 고른 것을 그대로 둔다(다시 해 볼 수 있게)', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(toast(), isNull); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test('휴지통 밖의 [삭제]는 휴지통으로 옮긴다 — 영구 삭제 요청을 보내지 않는다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.toggleSelection('m2'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(mail.deletions, isEmpty); + expect(idsOf(), ['m1']); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + expect(mail.idsIn(kTrashId), ['m2']); + }); + + test('휴지통에서는 영구 삭제 — deletedIds 만 빼고 되돌리기는 없다', () async { + mail.seed(kTrashId, 3, prefix: 't'); + mail.failIds.add('t2'); + final MailListController controller = await open(kTrashId); + controller + ..toggleSelection('t1') + ..toggleSelection('t2') + ..toggleSelection('t3'); + + await controller.purgeSelected(); + + expect(mail.deletions.single, ['t1', 't2', 't3']); + expect(idsOf(kTrashId), ['t2']); + expect(stateOf(kTrashId).pageInfo?.total, 1); + expect(toast(), isNull, reason: '영구 삭제는 되돌릴 수 없다 — 단추 알림이 아니다'); + expect(notices, [ + '메일 2통을 영구 삭제했습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ]); + }); + + test('100통씩 차례로 보낸다', () async { + mail.seed(kInboxId, 150); + final MailListController controller = await open(); + while (stateOf().hasMore) { + await controller.loadMore(); + } + for (final String id in idsOf()) { + controller.toggleSelection(id); + } + + await controller.moveSelectedToTrash(); + + expect( + mail.patches.map( + (Map body) => (body['ids']! as List).length, + ), + [100, 50], + ); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 150통을 휴지통으로 옮겼습니다.'); + }); + + test('휴지통을 모르면(메일함 목록에 없음) 옮기지 않고 알린다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches, isEmpty); + expect(h.notifier.shownCodes, ['BIZ_001']); + expect(stateOf().selectedIds, {'m1'}); + }); + }); + + group('새 메일 확인으로 맞추기', () { + test('바뀜을 알리면 보이는 목록을 불러온 만큼 다시 받아 맞춘다 — 고른 것은 그대로', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m2'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + + expect(idsOf(), ['new', 'm1', 'm2']); + expect(stateOf().selectedIds, {'m2'}); + expect(mail.listRequests.last.queryParameters['limit'], kMailPageSize); + }); + + test('가려진 동안의 바뀜은 다시 보일 때 맞춘다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(false); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(1)); + + controller.setVisible(true); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + }); + + test('스스로 바꾼 목록은 그 소식으로 다시 받지 않는다 — 다른 목록은 맞춘다', () async { + mail.seed(kInboxId, 2); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController inbox = await open(); + final MailListController clients = await open(kClientsId); + inbox.setVisible(true); + clients.setVisible(true); + final int before = mail.listRequests.length; + inbox.toggleSelection('m1'); + + await inbox.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + await pumpEventsUntil( + () => idsOf(kClientsId).contains('m1'), + reason: '거래처 목록', + ); + + expect( + mail.listRequests + .skip(before) + .map((RequestOptions r) => r.queryParameters['mailboxId']), + [kClientsId], + ); + }); + + test('맞추는 사이 이 목록을 바꿨으면 그 응답은 버리고 한 번 더 맞춘다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + gate.complete(); + await flagging; + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(stateOf().items.first.isFlagged, isTrue); + }); + }); + + group('경합 · 세션 · 되돌리기 (2026-11-06 리뷰)', () { + test('이어 받는 중 새로고침이 실패해도 끝 표시가 멈추지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + expect(stateOf().loadingMore, isTrue); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.refresh(); + gate.complete(); + await more; + + expect(stateOf().loadingMore, isFalse); + expect(stateOf().refreshing, isFalse); + expect(idsOf(), hasLength(20)); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + }); + + test('여러 쪽을 불러온 뒤 맞춘 쪽이 메일함 끝까지면 그 뒤의 옛 메일 · 커서를 남기지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().hasMore, isTrue); + controller.setVisible(true); + // 다른 곳에서 앞의 10통을 지웠다 — 서버에는 35통이 남았다 + mail.messages[kInboxId]!.removeRange(0, 10); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().length == 35, reason: '맞추기'); + + expect(mail.listRequests.last.queryParameters['limit'], 40); + expect(idsOf().first, 'm11'); + expect(idsOf().last, 'm45'); + expect(stateOf().hasMore, isFalse); + expect(stateOf().pageInfo?.nextCursor, isNull); + }); + + test('이어 받기가 맞추기보다 먼저 끝나면 그 맞추기 응답은 버리고 불러온 만큼 다시 맞춘다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 4, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(mail.listRequests[1].queryParameters['limit'], kMailPageSize); + expect(mail.listRequests[2].queryParameters['cursor'], 'c:20'); + expect(mail.listRequests[3].queryParameters['limit'], 40); + expect(idsOf(), hasLength(40)); + }); + + test('맞추기는 한 번에 하나 — 도는 동안 바뀜이 여러 번 오면 끝난 뒤 한 번만 더', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + final MailStateWatcher watcher = container.read( + mailStateWatcherProvider.notifier, + ); + watcher.markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + watcher + ..markChanged() + ..markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(2), reason: '도는 동안은 새로 묻지 않는다'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '끝난 뒤 한 번 더', + ); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞춤'); + await Future.delayed(Duration.zero); + + expect(mail.listRequests, hasLength(3)); + }); + + test('새로 붙은 메일 없이 같은 커서가 오면 끝에 [다시 시도] — 같은 커서로 저절로 다시 묻지 않는다', () async { + mail.seed(kInboxId, 25); + final MailListController controller = await open(); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await controller.loadMore(); + + expect(mail.listRequests, hasLength(2)); + expect(idsOf(), hasLength(20)); + expect(stateOf().loadingMore, isFalse); + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().hasMore, isTrue); + expect(h.notifier.shownCodes, isEmpty); + }); + + group('되돌리기는 실패한 칸만', () { + /// 요청마다 따로 답한다 — 시험이 차례를 정한다 + late List> replies; + final FakeResponse updated = FakeResponse.ok({ + 'updatedIds': ['m1'], + 'failedIds': [], + }); + final FakeResponse failed = FakeResponse.ok({ + 'updatedIds': [], + 'failedIds': ['m1'], + }); + + setUp(() { + replies = >[]; + mail.seed(kInboxId, 1); + h.server.on('PATCH', MailApiPaths.messages, (RequestOptions options) { + mail.patches.add(options.data! as Map); + final Completer reply = Completer(); + replies.add(reply); + return reply.future; + }); + }); + + test('중요 표시가 먼저 끝난 뒤 읽음 표시가 실패하면 읽음만 되돌린다', () async { + final MailListController controller = await open(); + controller.toggleSelection('m1'); + final Future reading = controller.toggleSelectedRead(); + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + expect(stateOf().items.single.isRead, isTrue); + expect(stateOf().items.single.isFlagged, isTrue); + await pumpEventsUntil(() => replies.length == 2, reason: '두 요청'); + + replies[1].complete(updated); + await flagging; + replies[0].complete(failed); + await reading; + + expect(stateOf().items.single.isRead, isFalse, reason: '읽음만 실패했다'); + expect( + stateOf().items.single.isFlagged, + isTrue, + reason: '먼저 끝난 중요 표시는 그대로', + ); + }); + + test('되돌린 칸에 아직 보내는 중인 표시가 있으면 그 값을 다시 입힌다', () async { + final MailListController controller = await open(); + // 읽음 → 안 읽음 → 읽음을 잇달아 누른다(셋 다 보내는 중) + final List> marks = >[]; + for (int n = 0; n < 3; n++) { + controller.toggleSelection('m1'); + marks.add(controller.toggleSelectedRead()); + } + expect(stateOf().items.single.isRead, isTrue); + await pumpEventsUntil(() => replies.length == 3, reason: '세 요청'); + expect( + mail.patches.map((Map b) => b['isRead']), + [true, false, true], + ); + + replies[0].complete(failed); + await marks[0]; + + expect( + stateOf().items.single.isRead, + isTrue, + reason: '마지막으로 보낸 읽음이 아직 오가는 중이다', + ); + replies[1].complete(updated); + replies[2].complete(updated); + await Future.wait(marks); + expect(stateOf().items.single.isRead, isTrue); + }); + }); + + test('읽음 표시 중에 목록이 사라져도 안 읽은 수를 되돌리고 다른 목록에 알린다', () async { + mail.seed(kInboxId, 2); + mail.failIds.add('m2'); + final ProviderSubscription boxes = container.listen( + mailboxesProvider, + (_, _) {}, + ); + addTearDown(boxes.close); + final ProviderSubscription changes = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(changes.close); + final ProviderSubscription list = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + int inboxUnread() => + container.read(mailboxesProvider).byId(kInboxId)!.unreadCount; + expect(inboxUnread(), 3); + final Completer gate = Completer(); + mail.patchGate = gate; + final MailListController controller = controllerOf() + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Future marking = controller.toggleSelectedRead(); + expect(inboxUnread(), 1); + + list.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + // 바뀐 소식으로 메일함 개수를 다시 받는 것은 문에서 붙잡아 둔다 — 되돌린 값을 보려고 + final Completer boxesGate = Completer(); + mail.mailboxesGate = boxesGate; + addTearDown(boxesGate.complete); + gate.complete(); + await marking; + + expect(inboxUnread(), 2, reason: '바꾸지 못한 m2 만큼 되돌린다'); + expect(container.read(mailStateWatcherProvider).seq, 1); + }); + + test('세션이 바뀌는 사이 끝난 옮기기는 새 세션에 알리지 않는다 — [되돌리기]도 없다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.patchGate = gate; + controller.toggleSelection('m1'); + final Future moving = controller.moveSelectedToTrash(); + await pumpEventsUntil(() => mail.patches.isNotEmpty, reason: '옮기기 요청'); + + // 로그아웃한 뒤 다른 직원이 로그인했다 + await h.store.clear(); + container.read(_accountProvider.notifier).set(null); + await h.store.save(tokensFor(2, now: h.clock.now)); + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + gate.complete(); + await moving; + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '새 세션의 목록', + ); + + expect(toast(), isNull); + expect(notices, isEmpty); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); + + test('휴지통을 모르다가 메일함 목록을 다시 받아 찾으면 그리로 옮긴다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + mail.mailboxes = standardMailboxes(); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(h.notifier.shownCodes, isEmpty); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + }); + + test('목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1'); + + controller.setVisible(false); + + expect(stateOf().selecting, isFalse); + }); + + test('다른 직원으로 바뀌어 새 메일 확인이 처음부터 다시 만들어져도 보이는 목록이 있으면 다시 묻는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + final ProviderSubscription watching = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watching.close); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '다시 물음'); + }); + + test('세션이 없는 동안 새 메일 확인이 사라졌다 다시 생겨도 보이는 목록은 새 것에 다시 알린다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set(null); + await pumpEventsUntil( + () => !container.exists(mailStateWatcherProvider), + reason: '아무도 듣지 않아 사라짐', + ); + container.read(_accountProvider.notifier).set(kMailTestAccount); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '새 것이 묻는다'); + }); + }); + + test('목록이 없어지면 새 메일 확인에서도 빠진다', () async { + mail.seed(kInboxId, 1); + final ProviderSubscription subscription = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + controllerOf().setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + final ProviderSubscription watcher = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watcher.close); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + await Future.delayed(Duration.zero); + + // 보는 목록이 없으니 새로 묻지 않는다 — 기준만 남는다 + expect(mail.stateRequests, 1); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); +} diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart new file mode 100644 index 0000000..9be493b --- /dev/null +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -0,0 +1,1085 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; +import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 메일 목록 화면 (앱 보드 `App1MailList`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + /// 시간을 넘긴다 — 손 시계와 시험 시간을 함께 + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + Finder avatarOf(String subject) => find.descendant( + of: row(subject), + matching: find.byType(MailAvatarButton), + ); + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + /// 단추 없는 알림 — 전역 알림 자리(스낵바)에 뜬다 + Finder noticeText(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + Future scrollToFooter(WidgetTester tester) async { + await tester.scrollUntilVisible( + find.byType(MailListFooter), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + Future longPress(WidgetTester tester, String subject) async { + await tester.longPress(find.text(subject)); + await tester.pumpAndSettle(); + } + + MailListPage currentPage(WidgetTester tester) => + tester.widget(find.byType(MailListPage)); + + /// 위에 쌓아 연 전체 화면(읽기 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 + String pushedLocation(WidgetTester tester) => GoRouterState.of( + tester.element(find.byType(PlaceholderPage).last), + ).uri.toString(); + + group('목록 줄', () { + testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', ( + WidgetTester tester, + ) async { + h.mail.messages[kInboxId] = >[ + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '이도윤', + subject: '유지보수 계약', + preview: '계약서 초안을 고쳐 첨부했습니다.', + hasAttachment: true, + isFlagged: true, + ), + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: '채용 일정', + isRead: true, + ), + ]; + await h.pump(tester); + + expect(find.text('받은편지함'), findsOneWidget); + expect(find.text(MailListTexts.unread(3)), findsOneWidget); + expect(find.text('이도윤'), findsOneWidget); + expect(find.text('계약서 초안을 고쳐 첨부했습니다.'), findsOneWidget); + expect(find.byIcon(Icons.attach_file), findsOneWidget); + expect(find.byIcon(Icons.star), findsOneWidget); + final Text unread = tester.widget(find.text('이도윤')); + final Text read = tester.widget(find.text('인사팀')); + expect(unread.style?.fontWeight, AppTypography.bold); + expect(read.style?.fontWeight, AppTypography.medium); + expect( + tester.getSemantics(row('유지보수 계약')).label, + startsWith('안 읽음, 이도윤, '), + ); + expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정'); + }); + + testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 읽음 처리는 하지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + + await tester.tap(find.text('제목 m1')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.message('m1')); + expect(find.text(MailTexts.message), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(h.mail.patches, isEmpty); + }); + + testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + expect(find.text('박준호'), findsOneWidget); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), '/mail/compose?draft=d1'); + }); + + testWidgets('보낸편지함 — 받는 사람과 보내는 중 · 예약 표시', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + expect(find.text('받는 사람: 박준호'), findsOneWidget); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(find.text(MailListTexts.unread(0)), findsNothing); + }); + }); + + group('메일함 서랍', () { + testWidgets('탭 막대 위까지 덮고, 메일함을 고르면 그 목록 — 뒤로 가기는 받은편지함', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + h.mail.seed(kClientsId, 1, prefix: 'c'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsOneWidget); + expect(find.text(MailDrawerTexts.myMailboxes), findsOneWidget); + for (final String name in [ + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text(name), + ), + findsOneWidget, + reason: name, + ); + } + // 서랍이 탭 막대를 덮는다 — 탭 막대 자리를 누르면 서랍 쪽이 받는다 + final Rect drawer = tester.getRect(find.byType(MailboxDrawer)); + final Rect bar = tester.getRect(find.byType(NavigationBar)); + expect(drawer.bottom, greaterThanOrEqualTo(bar.bottom)); + expect( + tester + .getSemantics( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('3'), + ), + ) + .label, + contains(MailDrawerTexts.unreadCount(3)), + ); + + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('거래처'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsNothing); + expect(h.location(), MailRoutes.mailbox(kClientsId)); + expect(currentPage(tester).mailboxId, kClientsId); + expect(find.text('제목 c1'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('메일함 목록을 받지 못하면 서랍에 사전 문구와 [다시 시도] — 다시 받으면 메일함', ( + WidgetTester tester, + ) async { + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + final Finder drawer = find.byType(MailboxDrawer); + expect( + find.descendant( + of: drawer, + matching: findCopy(ErrorLexicon.getMessage('MAIL_001')), + ), + findsOneWidget, + ); + h.mail.mailboxesFailure = null; + await tester.tap( + find.descendant(of: drawer, matching: find.text(MailListTexts.retry)), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant(of: drawer, matching: find.text('거래처')), + findsOneWidget, + ); + }); + + testWidgets('받은편지함을 고르면 첫 화면 주소로 돌아간다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('받은편지함'), + ), + ); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + }); + }); + + group('고르기', () { + testWidgets('길게 누르면 고르기 막대 — 탭 막대 · [메일 쓰기]를 숨기고 ✕ · 뒤로 가기로 나온다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + + await longPress(tester, '제목 m1'); + + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text(MailListTexts.compose), findsNothing); + // 고르는 중에는 줄을 눌러도 고르기 · 풀기 — 열지 않는다 + await tester.tap(find.text('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(2)), findsOneWidget); + expect(h.location(), MailRoutes.home); + await tester.tap(avatarOf('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.location(), MailRoutes.home); + + await tester.tap(avatarOf('제목 m3')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + await tester.tap(find.byTooltip(MailBulkTexts.clear)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + }); + + testWidgets('다른 탭으로 가면 고르기를 풀고 탭 막대가 보인다 — 돌아와도 그대로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + + h.router.go(MailRoutes.home); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('고르던 받은편지함 위에 다른 메일함을 열면 고르기를 풀고 그 화면에는 탭 막대가 있다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(MailRoutes.mailbox(kClientsId)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('아직 보내지 않은 메일은 고를 수 없다 — 까닭을 알린다', (WidgetTester tester) async { + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '예약한 메일', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(days: 1)), + kind: 'scheduled', + ), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + await tester.tap(avatarOf('예약한 메일')); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + + await longPress(tester, '예약한 메일'); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + final SemanticsNode avatar = tester.getSemantics(avatarOf('예약한 메일')); + expect(avatar.hint, MailListTexts.heldNotSelectable); + }); + + testWidgets('읽음 표시 — 먼저 굵은 글자가 풀리고 고르기를 끝낸다(알림 없음)', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byTooltip(MailBulkTexts.markRead), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.markRead)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['isRead'], isTrue); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MailToastCard), findsNothing); + final Text who = tester.widget( + find.descendant(of: row('제목 m1'), matching: find.text('이도윤')), + ); + expect(who.style?.fontWeight, AppTypography.medium); + }); + }); + + group('삭제 · 되돌리기 · 영구 삭제 · 이동', () { + testWidgets('삭제는 휴지통으로 옮기고 알림에 [되돌리기] — [메일 쓰기]가 알림 위로 함께 올라간다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + final double composeBottomBefore = tester + .getRect(find.text(MailListTexts.compose)) + .bottom; + await longPress(tester, '제목 m2'); + + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kTrashId); + expect(h.mail.deletions, isEmpty); + expect(find.text('제목 m2'), findsNothing); + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + final Rect toast = tester.getRect(find.byType(MailToastCard)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(compose.bottom, lessThan(toast.top)); + expect(compose.bottom, lessThan(composeBottomBefore)); + expect( + toast.bottom, + lessThan(tester.getRect(find.byType(NavigationBar)).top), + ); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.last, { + 'ids': ['m2'], + 'mailboxId': kInboxId, + }); + expect(noticeText('메일 1통을 되돌렸습니다.'), findsOneWidget); + expect(find.text('제목 m2'), findsOneWidget); + }); + + testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + await elapse(tester, kMailToastDuration - const Duration(seconds: 1)); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('단추 없는 알림은 [되돌리기] 알림을 바꾸지 않고 그 위(전역 알림 자리)에 뜬다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.patches.last['mailboxId'], kInboxId); + }); + + testWidgets('화면 낭독기를 쓰는 동안은 [되돌리기] 알림을 저절로 닫지 않는다 — 끄면 그때부터 10초', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown( + tester.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await elapse(tester, kMailToastActionDuration * 3); + expect(find.byType(MailToastCard), findsOneWidget); + + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(); + await tester.pump(); + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + }); + + testWidgets('고르는 중(탭 막대가 숨음)의 알림 줄은 시스템 내비게이션 · 옆 안전 영역 안쪽에 뜬다', ( + WidgetTester tester, + ) async { + // 휴대폰의 안전 영역 — 위 24 · 아래 16 · 좌우 12 (물리 픽셀은 3배) + tester.view.padding = const FakeViewPadding( + left: 36, + top: 72, + right: 36, + bottom: 48, + ); + h.mail.seed(kInboxId, 3); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + // 알림이 떠 있는 동안 다시 고른다 — 탭 막대가 숨어 화면 아래가 시스템 내비게이션 자리까지 내려온다 + await longPress(tester, '제목 m2'); + expect(find.byType(NavigationBar), findsNothing); + + final Rect toast = tester.getRect(find.byType(MailToastCard)); + expect( + toast.bottom, + moreOrLessEquals(kPhoneSize.height - 16 - AppSpacing.s12), + ); + expect(toast.left, moreOrLessEquals(12 + AppSpacing.s12)); + expect( + toast.right, + moreOrLessEquals(kPhoneSize.width - 12 - AppSpacing.s12), + ); + }); + + testWidgets('휴지통에서는 영구 삭제 — 확인 창(처음 초점 [취소]) 뒤에만 보낸다', ( + WidgetTester tester, + ) async { + h.mail.seed(kTrashId, 2, prefix: 't'); + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + expect( + findCopy(MailListTexts.retentionNotice(MailboxRole.trash)!), + findsOneWidget, + ); + await longPress(tester, '제목 t1'); + expect(find.byTooltip(MailBulkTexts.purge), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.purgeTitle(1)), findsOneWidget); + expect(findCopy(MailBulkTexts.purgeMessage), findsOneWidget); + final BuildContext focused = FocusManager.instance.primaryFocus!.context!; + expect( + find.descendant( + of: find.byWidget(focused.widget), + matching: find.text(MailBulkTexts.purgeCancel), + ), + findsOneWidget, + ); + await tester.tap(find.text(MailBulkTexts.purgeCancel)); + await tester.pumpAndSettle(); + expect(h.mail.deletions, isEmpty); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, MailBulkTexts.purgeConfirm), + ); + await tester.pumpAndSettle(); + + expect(h.mail.deletions.single, ['t1']); + expect(h.mail.patches, isEmpty); + expect(find.text('제목 t1'), findsNothing); + expect(noticeText('메일 1통을 영구 삭제했습니다.'), findsOneWidget); + expect(find.text(MailBulkTexts.undo), findsNothing); + }); + + testWidgets('이동 — 시트에 옮길 수 있는 메일함만(지금 · 임시보관함 · 휴지통 빼고), 고르면 그리로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + + await tester.tap(find.byTooltip(MailBulkTexts.move)); + await tester.pumpAndSettle(); + + final Finder sheet = find.byType(BottomSheet); + expect( + find.descendant( + of: sheet, + matching: find.text(MailBulkTexts.moveSheetTitle), + ), + findsOneWidget, + ); + for (final String name in [ + '보낸편지함', + '스팸메일함', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsOneWidget, + reason: name, + ); + } + for (final String name in ['받은편지함', '임시보관함', '휴지통']) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsNothing, + reason: name, + ); + } + await tester.tap(find.descendant(of: sheet, matching: find.text('거래처'))); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kClientsId); + expect(toastText('메일 1통을 거래처로 옮겼습니다.'), findsOneWidget); + }); + }); + + group('상태 · 새로고침 · 이어 받기', () { + testWidgets('빈 휴지통 — 30일 안내 글', (WidgetTester tester) async { + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + + expect(findCopy('휴지통이 비어 있습니다'), findsOneWidget); + expect( + findCopy('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.'), + findsOneWidget, + ); + expect(find.byType(MailRetentionNotice), findsNothing); + }); + + testWidgets('받지 못하면 사전 문구 · 문의 코드 · [다시 시도]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-MA')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.listFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('없는 메일함 주소면 [받은편지함으로]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox('gone')); + + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.text(MailListTexts.toInbox)); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('당겨서 새로고침 — 첫 쪽을 다시 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + h.mail.arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새로 온 메일'), + ); + + await tester.fling(find.text('제목 m1'), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새로 온 메일'), findsOneWidget); + }); + + testWidgets('끝 근처에 닿으면 다음 쪽을 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + expect(h.mail.listRequests, hasLength(1)); + + await tester.scrollUntilVisible( + find.text('제목 m20'), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(h.mail.listRequests.last.queryParameters['cursor'], 'c:20'); + await tester.scrollUntilVisible( + find.text('제목 m40'), + 500, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('제목 m40'), findsOneWidget); + }); + + testWidgets('받은 쪽이 모두 이미 있는 메일이어도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 받는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + // 그사이 새 메일 20통이 와서 다음 쪽(c:20)이 모두 이미 보이는 메일이다 + for (int n = 20; n >= 1; n--) { + h.mail.arrive(kInboxId, messageJson('new$n', mailboxId: kInboxId)); + } + + await scrollToFooter(tester); + + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['cursor'], + ), + [null, 'c:20', 'c:40'], + ); + expect(find.text('제목 m21'), findsOneWidget); + }); + + testWidgets('같은 커서가 새 메일 없이 다시 오면 끝에 [다시 시도] — 저절로 되풀이하지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 25); + await h.pump(tester); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await scrollToFooter(tester); + + expect(h.mail.listRequests, hasLength(2)); + final Finder retry = find.descendant( + of: find.byType(MailListFooter), + matching: find.text(MailListTexts.retry), + ); + expect(retry, findsOneWidget); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(h.mail.listRequests, hasLength(3)); + expect(retry, findsOneWidget); + }); + + testWidgets('메일함 목록을 받기 전에는 목록 대신 뼈대 — 받은 뒤 임시보관함 줄은 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson('d1', mailboxId: kDraftsId, subject: '쓰던 메일'), + ]; + final Completer boxes = Completer(); + h.mail.mailboxesGate = boxes; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + // 목록은 받아 두었지만 역할(임시보관함)을 알기 전이라 그리지 않는다 + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['mailboxId'], + ), + contains(kDraftsId), + ); + expect(find.byType(MailListSkeleton), findsOneWidget); + expect(find.text('쓰던 메일'), findsNothing); + + boxes.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.composeDraft('d1')); + }); + + testWidgets('다른 메일함 주소에서 메일함 목록을 받지 못하면 그 실패와 [다시 시도] — 다시 받으면 목록', ( + WidgetTester tester, + ) async { + h.mail.seed(kClientsId, 1, prefix: 'c'); + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text('제목 c1'), findsNothing); + + h.mail.mailboxesFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + + expect(find.text('제목 c1'), findsOneWidget); + expect(find.text('거래처'), findsOneWidget); + }); + + testWidgets('[찾기]는 아직 준비 중이라 알리고, [메일 쓰기]는 쓰기 화면을 연다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + + await tester.tap(find.text(MailListTexts.compose)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.compose); + expect(find.byType(NavigationBar), findsNothing); + }); + + testWidgets('전역 알림(API 실패)은 이 화면에 뜨고 [메일 쓰기] · 알림 줄을 가리지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + h.container + .read(scaffoldMessengerKeyProvider) + .currentState! + .showSnackBar( + SnackBar(content: Text(ErrorLexicon.getMessage('MAIL_001'))), + ); + await tester.pumpAndSettle(); + + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + expect( + find.descendant( + of: find.byType(MailListPage), + matching: find.byType(SnackBar), + ), + findsOneWidget, + ); + }); + }); + + group('새 메일 확인 (1분 · 손 시계)', () { + testWidgets('화면이 보이는 동안 1분마다 묻는다 — 처음 값은 기준, 바뀌면 불러온 쪽을 맞춘다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + expect(h.mail.listRequests, hasLength(1)); + + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 1)); + expect(h.mail.stateRequests, 1); + await elapse(tester, const Duration(seconds: 1)); + expect(h.mail.stateRequests, 2); + expect(h.mail.listRequests, hasLength(1), reason: '값이 같으면 맞추지 않는다'); + + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 메일'), + ); + await elapse(tester, kMailStatePollInterval); + + expect(h.mail.stateRequests, 3); + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새 메일'), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('다른 탭에 있는 동안은 묻지 않고, 돌아오면 10초가 지났으니 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('설정')); + await tester.pumpAndSettle(); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('메일')); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + }); + + testWidgets('앱이 뒤로 가면 멈추고, 앞으로 오면 마지막으로 물은 지 10초가 지났을 때만 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + void lifecycle(List states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + const List toBackground = [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]; + const List toForeground = [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]; + + lifecycle(toBackground); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + + // 5초 만에 다시 왔다 — 바로 묻지 않고 원래 차례(마지막으로 물은 지 1분)에 + lifecycle(toBackground); + await elapse(tester, const Duration(seconds: 5)); + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 5)); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('확인이 실패해도 알리지 않고 다음 차례에 다시 묻는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.mail.stateFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 2); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.stateFailure = null; + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('아직 보내지 않은 메일은 보낼 시각 + 2초에 목록을 다시 본다(엔진이 풀어도 상태가 바뀌지 않는다)', ( + WidgetTester tester, + ) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + group('아직 보내지 않은 메일 다시 보기 (손 시계)', () { + testWidgets('다시 보다 실패해도(연결 끊김) 5초 뒤 다시 본다', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + final int before = h.mail.listRequests.length; + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty, reason: '맞추기 실패는 알리지 않는다'); + + h.mail.listFailure = null; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + await elapse(tester, kHeldReleaseRecheck); + + expect(h.mail.listRequests.length, before + 2); + expect(find.text(MailListTexts.sending), findsNothing); + }); + + testWidgets('보낼 시각은 서버 시계로 잰다 — 기기 시계가 5분 늦어도 제때 다시 본다', ( + WidgetTester tester, + ) async { + final DateTime serverNow = h.clock.now.add(const Duration(minutes: 5)); + h.server.serverTime = serverNow; + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(serverNow.add(const Duration(seconds: 8))), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + testWidgets('세션이 끝나면(로그아웃) 메일 상태가 사라진다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.server.on( + 'POST', + '/auth/app/logout', + (RequestOptions _) => FakeResponse.ok(), + ); + h.container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + await tester.pumpAndSettle(); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + await tester.tap(find.text('로그아웃')); + await tester.pumpAndSettle(); + + expect(find.byType(MailListPage), findsNothing); + expect(h.container.exists(mailToastProvider), isFalse); + expect(h.container.exists(mailStateWatcherProvider), isFalse); + }); +} diff --git a/flutter/test/features/mail/mail_repository_test.dart b/flutter/test/features/mail/mail_repository_test.dart new file mode 100644 index 0000000..edc67ad --- /dev/null +++ b/flutter/test/features/mail/mail_repository_test.dart @@ -0,0 +1,205 @@ +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다) +void main() { + late ApiHarness h; + late MailRepository repository; + + setUp(() async { + h = ApiHarness(); + await h.prepare(); + await h.signIn(); + repository = MailRepository(h.dio); + }); + + test('메일함 목록 — Bearer 로 부르고 읽는다, 실패는 알리지 않는다', () async { + h.server.on( + 'GET', + MailApiPaths.mailboxes, + (_) => FakeResponse.ok({ + 'items': standardMailboxes(), + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + + final List mailboxes = await repository.listMailboxes(); + + expect(mailboxes, hasLength(8)); + expect(mailboxes.first.role, MailboxRole.inbox); + expect(bearerTokenOf(h.server.requests.single), 'access-1'); + + h.server.on( + 'GET', + MailApiPaths.mailboxes, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + expect( + apiErrorOf(await errorOf(repository.listMailboxes())).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('목록 — 메일함 · 쪽 크기 · 커서를 그대로 보낸다', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': 21, + }, + }), + ); + + final MailPage page = await repository.listMessages( + mailboxId: kInboxId, + cursor: 'abc', + limit: 50, + ); + + expect(page.items.single.id, 'm1'); + expect(page.pageInfo.nextCursor, 'opaque=='); + expect(page.pageInfo.total, 21); + expect(h.server.requests.single.queryParameters, { + 'mailboxId': kInboxId, + 'limit': 50, + 'cursor': 'abc', + }); + }); + + test('목록 — 응답의 Date(서버 시각)를 쪽에 싣는다, 없으면 null', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + final DateTime serverTime = DateTime.utc(2026, 11, 6, 1, 2, 3); + h.server.serverTime = serverTime; + + final MailPage page = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(page.serverTime, serverTime); + + h.server.serverTime = null; + final MailPage withoutDate = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(withoutDate.serverTime, isNull); + }); + + test('응답 모양이 계약과 다르면 SYS_001', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'm1'}, + ], + 'pageInfo': {'hasNext': false}, + }), + ); + h.server.on( + 'GET', + MailApiPaths.mailState, + (_) => FakeResponse.ok({'state': 3}), + ); + + expect( + apiErrorOf( + await errorOf(repository.listMessages(mailboxId: kInboxId)), + ).code, + 'SYS_001', + ); + expect(apiErrorOf(await errorOf(repository.mailState())).code, 'SYS_001'); + }); + + test('한 통 · 여러 통 바꾸기 · 영구 삭제의 경로와 본문', () async { + h.server.on( + 'PATCH', + MailApiPaths.message('a/b'), + (RequestOptions options) => FakeResponse.ok( + messageJson('a/b', mailboxId: kInboxId, isRead: true), + ), + ); + h.server.on( + 'PATCH', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'updatedIds': ['m1'], + 'failedIds': ['m2'], + }), + ); + h.server.on( + 'POST', + MailApiPaths.messageDeletions, + (_) => FakeResponse.ok({ + 'deletedIds': ['t1'], + 'skippedIds': [], + }), + ); + + final MessageSummary one = await repository.updateMessage( + 'a/b', + const MessageChanges(isRead: true), + ); + final MessagesPatched many = await repository.updateMessages([ + 'm1', + 'm2', + ], const MessageChanges(mailboxId: kTrashId)); + final MessageDeletions deleted = await repository.deleteMessages([ + 't1', + ]); + + expect(MailApiPaths.message('a/b'), '/messages/a%2Fb'); + expect(one.isRead, isTrue); + expect(many.failedIds, ['m2']); + expect(deleted.deletedIds, ['t1']); + expect(h.server.requests[0].data, {'isRead': true}); + expect(h.server.requests[1].data, { + 'ids': ['m1', 'm2'], + 'mailboxId': kTrashId, + }); + expect(h.server.requests[2].data, { + 'ids': ['t1'], + }); + }); + + test('여러 통 바꾸기의 실패는 인터셉터가 알린다(새 메일 확인은 알리지 않는다)', () async { + h.server.on( + 'PATCH', + MailApiPaths.messages, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on( + 'GET', + MailApiPaths.mailState, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + + await errorOf( + repository.updateMessages([ + 'm1', + ], const MessageChanges(isRead: true)), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + h.clock.advance(const Duration(seconds: 5)); + await errorOf(repository.mailState()); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); +} diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart new file mode 100644 index 0000000..d87ebd7 --- /dev/null +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -0,0 +1,521 @@ +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/mail_bulk.dart'; +import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 메일 목록의 순수 규칙 — 표기 · 조사 · 줄 · 메일함 차례 · 병합 · 나눠 보내기 · 모델 읽기 (웹 규칙과 같은지) +void main() { + /// 기기 시간대의 시각을 UTC 로 — 시험이 CI(UTC) · 개발 PC(KST) 어디서나 같게 (테스트 규칙 "시간대도 입력이다") + DateTime local(int y, int m, int d, [int h = 0, int min = 0]) => + DateTime(y, m, d, h, min).toUtc(); + + MessageSummary summary(Map json) => + MessageSummary.tryParse(json)!; + + group('숫자 · 시각 표기 (웹 format.ts 와 같은 글)', () { + test('개수에 천 단위 쉼표', () { + expect(formatCount(0), '0'); + expect(formatCount(999), '999'); + expect(formatCount(1000), '1,000'); + expect(formatCount(1234567), '1,234,567'); + }); + + test('하루 안의 시각 — 오전 · 오후 · 열두 시', () { + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 10, 42)), '오전 10:42'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 15, 5)), '오후 3:05'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 0, 30)), '오전 12:30'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 12)), '오후 12:00'); + }); + + test('목록의 받은 시각 — 오늘 · 어제 · 올해 · 그 전', () { + final DateTime now = local(2026, 11, 6, 9, 12); + expect(formatMailListTime(local(2026, 11, 6, 8, 31), now), '오전 8:31'); + expect(formatMailListTime(local(2026, 11, 5, 23, 59), now), '어제'); + expect(formatMailListTime(local(2026, 11, 3, 10), now), '11월 3일'); + expect(formatMailListTime(local(2025, 10, 24, 10), now), '2025. 10. 24.'); + }); + + test('어제는 달 · 해의 경계를 넘는다', () { + expect( + formatMailListTime(local(2025, 12, 31, 18), local(2026, 1, 1, 8)), + '어제', + ); + expect( + formatMailListTime(local(2026, 2, 28, 18), local(2026, 3, 1, 8)), + '어제', + ); + }); + + test('붙잡아 둔 발송 — 보내는 중 · 예약 시각(요일 없이)', () { + expect( + pendingSendLabel( + PendingSend( + sendAt: local(2026, 11, 6, 9, 13), + kind: PendingSendKind.undo, + ), + ), + '보내는 중', + ); + expect( + pendingSendLabel( + PendingSend( + sendAt: local(2026, 11, 7, 9), + kind: PendingSendKind.scheduled, + ), + ), + '예약 · 11월 7일 오전 9:00', + ); + }); + }); + + group('조사 (웹 korean.ts 와 같다)', () { + test('이/가 — 받침 · 받침 없음 · 한글이 아님', () { + expect(subjectParticle('휴지통'), '이'); + expect(subjectParticle('프로젝트'), '가'); + expect(subjectParticle('Inbox'), '이(가)'); + expect(MailListTexts.emptyTitle('휴지통'), '휴지통이 비어 있습니다'); + expect(MailListTexts.emptyTitle('프로젝트'), '프로젝트가 비어 있습니다'); + }); + + test('으로/로 — 받침 · ㄹ 받침 · 받침 없음 · 한글이 아님', () { + expect(directionParticle('휴지통'), '으로'); + expect(directionParticle('보낸편지함'), '으로'); + expect(directionParticle('메일'), '로'); + expect(directionParticle('프로젝트'), '로'); + expect(directionParticle('Archive'), '(으)로'); + }); + }); + + group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () { + test('옮김 · 영구 삭제 · 되돌림, 그대로 둔 메일은 뒤에 덧붙인다', () { + expect(MailBulkTexts.moved(2, '휴지통'), '메일 2통을 휴지통으로 옮겼습니다.'); + expect( + MailBulkTexts.moved(1, '프로젝트', skipped: 1), + '메일 1통을 프로젝트로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ); + expect( + MailBulkTexts.moved(0, '휴지통', skipped: 2), + '2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ); + expect(MailBulkTexts.purged(1234), '메일 1,234통을 영구 삭제했습니다.'); + expect(MailBulkTexts.restored(3), '메일 3통을 되돌렸습니다.'); + expect(MailBulkTexts.purgeTitle(2), '메일 2통을 영구 삭제할까요?'); + }); + + test('위 막대 아래 안 읽은 수', () { + expect(MailListTexts.unread(3), '안 읽은 메일 3통'); + expect(MailListTexts.unread(0), '안 읽은 메일이 없습니다'); + }); + }); + + group('목록 줄 (웹 mail-list-row.ts 의 분할 보기와 같다)', () { + final DateTime now = local(2026, 11, 6, 11); + + test('받은편지함 — 보낸 사람 · 머리글자 · 안 읽음 · 첨부 · 중요 · 미리 보기', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '(주)한빛솔루션', + subject: '견적서', + preview: '첨부했습니다.', + receivedAt: local(2026, 11, 6, 10, 42), + hasAttachment: true, + isFlagged: true, + ), + ), + role: MailboxRole.inbox, + now: now, + ); + expect(view.who, '(주)한빛솔루션'); + expect(view.initial, '한'); + expect(view.time, '오전 10:42'); + expect(view.unread, isTrue); + expect(view.held, isFalse); + expect(view.selectLabel, '고르기: 견적서'); + expect( + view.semanticsLabel, + '안 읽음, (주)한빛솔루션, 오전 10:42, 견적서, 첨부 있음, 중요, 첨부했습니다.', + ); + }); + + test('이름이 없으면 주소, 보낸 사람이 없으면 "(보낸 사람 없음)" · 사람 아이콘', () { + final MailRowView noName = mailRowViewOf( + summary( + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '', + fromEmail: 'bot@example.com', + ), + ), + role: MailboxRole.inbox, + now: now, + ); + expect(noName.who, 'bot@example.com'); + expect(noName.initial, 'B'); + final MailRowView noSender = mailRowViewOf( + summary(messageJson('m2', mailboxId: kInboxId, noSender: true)), + role: MailboxRole.inbox, + now: now, + ); + expect(noSender.who, MailListTexts.noSender); + expect(noSender.initial, isNull); + }); + + test('보낸편지함 — "받는 사람: 박준호 외 2명", 받는 사람이 없으면 "받는 사람 없음"', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 's1', + mailboxId: kSentId, + to: >[ + addressJson('박준호', 'junho@example.com'), + addressJson('', 'b@example.com'), + addressJson('최서연', 'c@example.com'), + ], + isRead: true, + ), + ), + role: MailboxRole.sent, + now: now, + ); + expect(view.who, '받는 사람: 박준호 외 2명'); + expect(view.initial, '박'); + final MailRowView none = mailRowViewOf( + summary(messageJson('s2', mailboxId: kSentId)), + role: MailboxRole.sent, + now: now, + ); + expect(none.who, MailListTexts.noRecipients); + expect(none.whoPlaceholder, isFalse); + }); + + test('임시보관함 — 받는 사람을 쉼표로, 없으면 흐린 "(받는 사람 없음)", 읽음 여부 · 빈 제목', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 'd1', + mailboxId: kDraftsId, + to: >[ + addressJson('박준호', 'junho@example.com'), + addressJson('', 'infra@example.com'), + ], + ), + ), + role: MailboxRole.drafts, + now: now, + ); + expect(view.who, '박준호, infra@example.com'); + expect(view.unread, isFalse, reason: '임시 저장본은 읽음 여부를 보이지 않는다'); + final MailRowView empty = mailRowViewOf( + summary(messageJson('d2', mailboxId: kDraftsId, subject: '')), + role: MailboxRole.drafts, + now: now, + ); + expect(empty.who, MailListTexts.draftNoRecipients); + expect(empty.whoPlaceholder, isTrue); + expect(empty.subject, MailListTexts.noSubject); + expect(empty.subjectPlaceholder, isTrue); + }); + + test('아직 보내지 않은 메일 — 시각 자리에 표시(청록) · 고를 수 없다', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(local(2026, 11, 7, 9), kind: 'scheduled'), + ), + ), + role: MailboxRole.sent, + now: now, + ); + expect(view.time, '예약 · 11월 7일 오전 9:00'); + expect(view.timePending, isTrue); + expect(view.held, isTrue); + }); + }); + + group('모델 읽기', () { + test('메일 — 칸이 틀리면 null, 붙잡아 둔 발송만은 틀리면 없는 것으로', () { + final Map json = messageJson('m1', mailboxId: kInboxId); + expect(summary(json).pendingSend, isNull); + expect( + MessageSummary.tryParse({...json, 'id': 1}), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'mailboxIds': [1], + }), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'receivedAt': 'nope', + }), + isNull, + ); + expect( + summary({ + ...json, + 'pendingSend': {'kind': 'later', 'sendAt': 'x'}, + }).pendingSend, + isNull, + ); + // 9단계 앞 서버 — 칸이 없다 + expect( + summary({...json}..remove('pendingSend')).isHeld, + isFalse, + ); + }); + + test('메일함 — 모르는 역할은 내 메일함, 목록 쪽 — 항목 하나라도 틀리면 null', () { + expect( + Mailbox.tryParse(mailboxJson('x', '보관', role: 'mystery'))!.role, + isNull, + ); + expect( + Mailbox.tryParse(mailboxJson('x', '받은편지함', role: 'inbox'))!.role, + MailboxRole.inbox, + ); + final MailPage? page = MailPage.tryParse( + { + 'items': [ + mailboxJson('a', 'A'), + {'id': 2}, + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }, + Mailbox.tryParse, + ); + expect(page, isNull); + }); + + test('다음 쪽은 hasNext 이고 커서가 있을 때만', () { + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 40, + })!.hasMore, + isTrue, + ); + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': null, + })!.hasMore, + isFalse, + ); + }); + + test('바꿀 것 — 정한 칸만 싣는다', () { + expect(const MessageChanges(isRead: true).toJson(), { + 'isRead': true, + }); + expect( + const MessageChanges(mailboxId: 'mb-trash', isJunk: false).toJson(), + {'mailboxId': 'mb-trash', 'isJunk': false}, + ); + }); + }); + + group('메일함 차례 · 묶음 · 옮길 곳 (웹 mailbox.ts 와 같다)', () { + final List mailboxes = [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ]; + + test('역할 차례 → 내 메일함(sortOrder · 이름) — 하위는 부모 바로 아래 한 단 깊이', () { + final List nodes = orderMailboxes(mailboxes); + expect( + [for (final MailboxNode node in nodes) node.mailbox.name], + [ + '받은편지함', + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ], + ); + expect(nodes.last.depth, 1); + final ({List system, List mine}) groups = + splitMailboxGroups(nodes); + expect(groups.system, hasLength(5)); + expect(groups.mine, hasLength(3)); + }); + + test('내 메일함 이름은 대소문자를 가리지 않고 글자 값으로 — 영문 · 한글이 섞이면 웹(ko 정렬)과 다를 수 있다', () { + final List mine = [ + for (final (String id, String name) in <(String, String)>[ + ('1', '가나다'), + ('2', 'erp'), + ('3', 'ARA 프로젝트'), + ('4', 'ara'), + ('5', 'Ara'), + ]) + Mailbox.tryParse(mailboxJson(id, name))!, + ]; + + expect( + [ + for (final MailboxNode node in orderMailboxes(mine)) + node.mailbox.name, + ], + // 같은 글(대소문자만 다름)은 원래 글자로 한 번 더 견준다 — 늘 같은 차례 + ['Ara', 'ara', 'ARA 프로젝트', 'erp', '가나다'], + ); + }); + + test('부모를 찾을 수 없거나 서로를 가리키는 메일함도 빠뜨리지 않는다', () { + final List broken = [ + Mailbox.tryParse(mailboxJson('a', 'A', parentId: 'b'))!, + Mailbox.tryParse(mailboxJson('b', 'B', parentId: 'a'))!, + Mailbox.tryParse(mailboxJson('c', 'C', parentId: 'gone'))!, + ]; + expect(orderMailboxes(broken), hasLength(3)); + }); + + test('옮길 곳은 지금 메일함 · 임시보관함 · 휴지통을 뺀다', () { + expect( + [ + for (final MailboxNode node in moveTargetsOf(mailboxes, kInboxId)) + node.mailbox.id, + ], + [kSentId, kJunkId, kClientsId, kProjectId, kProjectChildId], + ); + }); + + test('첫 화면은 받은편지함, 안 읽은 수는 보낸편지함 · 임시보관함 · 휴지통에 없다', () { + expect(defaultMailboxOf(mailboxes)!.id, kInboxId); + expect(showsUnreadCount(MailboxRole.inbox), isTrue); + expect(showsUnreadCount(null), isTrue); + expect(showsUnreadCount(MailboxRole.sent), isFalse); + expect(showsUnreadCount(MailboxRole.drafts), isFalse); + expect(showsUnreadCount(MailboxRole.trash), isFalse); + expect(MailListTexts.retentionNotice(MailboxRole.trash), isNotNull); + expect(MailListTexts.retentionNotice(MailboxRole.junk), isNotNull); + expect(MailListTexts.retentionNotice(MailboxRole.inbox), isNull); + }); + }); + + group('새 메일 확인의 병합 (웹 mail-list-merge.ts 와 같다)', () { + MessageSummary m(String id) => + summary(messageJson(id, mailboxId: kInboxId)); + const MailPageInfo oldInfo = MailPageInfo( + hasNext: true, + nextCursor: 'c:40', + total: 50, + ); + const MailPageInfo newInfo = MailPageInfo( + hasNext: true, + nextCursor: 'c:20', + total: 51, + ); + + test('다시 받은 범위는 서버 기준, 그 뒤의 옛 메일은 남기고 옛 커서를 쓴다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a'), m('b'), m('c'), m('d')], + currentPageInfo: oldInfo, + fetched: [m('new'), m('a'), m('c')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'c', 'd'], + reason: '다른 곳에서 지운 b 는 빠지고, 범위 밖의 d 는 남는다', + ); + expect(merged.pageInfo.nextCursor, 'c:40'); + expect(merged.pageInfo.total, 51); + }); + + test('다시 받은 쪽이 끝까지였으면 받지 못한 옛 메일은 지워진 것이라 남기지 않는다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a'), m('b'), m('c')], + currentPageInfo: const MailPageInfo(hasNext: false), + fetched: [m('new'), m('a'), m('b')], + fetchedPageInfo: const MailPageInfo(hasNext: false, total: 3), + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'b'], + ); + }); + + test('겹치는 메일이 없으면 다시 받은 것만 쓴다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a')], + currentPageInfo: oldInfo, + fetched: [m('x'), m('y')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['x', 'y'], + ); + expect(merged.pageInfo, newInfo); + }); + }); + + group('나눠 보내기 · 되돌리기 묶음', () { + test('100통씩 나눈다', () { + final List ids = [for (int i = 0; i < 250; i++) '$i']; + expect(chunked(ids, 100).map((List c) => c.length), [ + 100, + 100, + 50, + ]); + }); + + test('요청이 실패하면 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다', () async { + int calls = 0; + final BulkResult result = await sendInChunks( + [for (int i = 0; i < 250; i++) '$i'], + 100, + (List chunk) async { + calls++; + if (calls == 2) { + throw DioException(requestOptions: RequestOptions(path: '/x')); + } + return (done: chunk.sublist(1), skipped: chunk.sublist(0, 1)); + }, + ); + expect(calls, 2); + expect(result.doneIds, hasLength(99)); + expect(result.skippedIds, ['0']); + expect(result.unsentIds, hasLength(150)); + expect(result.hasLeftovers, isTrue); + }); + + test('원래 메일함별로 묶는다 — 모르는 메일은 뺀다', () { + final List groups = groupByOrigin( + ['a', 'b', 'c', 'd'], + {'a': kInboxId, 'b': kClientsId, 'c': kInboxId}, + ); + expect(groups, hasLength(2)); + expect(groups.first.mailboxId, kInboxId); + expect(groups.first.ids, ['a', 'c']); + expect(groups.last.ids, ['b']); + }); + }); +} diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart new file mode 100644 index 0000000..5efeed2 --- /dev/null +++ b/flutter/test/support/mail_fakes.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; + +import 'fakes.dart'; + +/// 시험 메일함 id +const String kInboxId = 'mb-inbox'; +const String kSentId = 'mb-sent'; +const String kDraftsId = 'mb-drafts'; +const String kJunkId = 'mb-junk'; +const String kTrashId = 'mb-trash'; +const String kClientsId = 'mb-clients'; +const String kProjectId = 'mb-project'; +const String kProjectChildId = 'mb-project-child'; + +/// 메일함 한 칸 (계약 2절 `Mailbox`) +Map mailboxJson( + String id, + String name, { + String? role, + String? parentId, + int sortOrder = 0, + int total = 0, + int unread = 0, +}) => { + 'id': id, + 'name': name, + 'role': role, + 'parentId': parentId, + 'sortOrder': sortOrder, + 'totalCount': total, + 'unreadCount': unread, +}; + +/// 메일 한 통 (계약 2절 `MessageSummary`) +Map messageJson( + String id, { + required String mailboxId, + String fromName = '이도윤', + String fromEmail = 'doyun.lee@example.com', + List> to = const >[], + String? subject, + String preview = '미리 보기 글', + DateTime? receivedAt, + bool isRead = false, + bool isFlagged = false, + bool hasAttachment = false, + Map? pendingSend, + bool noSender = false, +}) => { + 'id': id, + 'threadId': 'thread-$id', + 'mailboxIds': [mailboxId], + 'from': noSender + ? null + : {'name': fromName, 'email': fromEmail}, + 'to': to, + 'subject': subject ?? '제목 $id', + 'preview': preview, + 'receivedAt': (receivedAt ?? kTestNow).toUtc().toIso8601String(), + 'isRead': isRead, + 'isFlagged': isFlagged, + 'hasAttachment': hasAttachment, + 'size': 1024, + 'pendingSend': pendingSend, +}; + +/// 받는 사람 한 사람 +Map addressJson(String name, String email) => + {'name': name, 'email': email}; + +/// 붙잡아 둔 발송 (9단계) +Map pendingJson(DateTime sendAt, {String kind = 'undo'}) => + {'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind}; + +/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나) +List> standardMailboxes({ + int inboxUnread = 3, +}) => >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox', total: 30, unread: inboxUnread), + mailboxJson(kSentId, '보낸편지함', role: 'sent', total: 5), + mailboxJson(kDraftsId, '임시보관함', role: 'drafts', total: 1), + mailboxJson(kJunkId, '스팸메일함', role: 'junk', total: 2, unread: 2), + mailboxJson(kTrashId, '휴지통', role: 'trash'), + mailboxJson(kClientsId, '거래처', total: 1, unread: 1), + mailboxJson(kProjectId, '프로젝트', sortOrder: 1), + mailboxJson(kProjectChildId, '설계', parentId: kProjectId), +]; + +/// 메일 API 를 흉내 내는 가짜 뒤쪽 — 메일함 · 목록(커서) · 여러 통 바꾸기 · 영구 삭제 · 새 메일 확인. +/// +/// 상태는 메모리에 두고 요청대로 바꾼다. [failIds] 는 바꾸기 · 지우기가 그대로 둘 메일(`failedIds` · `skippedIds`), +/// [patchFailure] · [listFailure] 는 그 요청을 그 응답으로 실패시킨다. 문([listGate] 등)을 두면 그 응답이 문이 열릴 때까지 기다린다. +class FakeMailBackend { + FakeMailBackend(this.server, {List>? mailboxes}) + : mailboxes = mailboxes ?? standardMailboxes(); + + final FakeServer server; + List> mailboxes; + + /// 메일함마다 메일 — 받은 시각 내림차순(앞이 최신) + final Map>> messages = + >>{}; + + /// 새 메일 확인의 상태 글 + String state = 'state-1'; + + /// 바꾸기 · 지우기가 그대로 둘 메일 id + final Set failIds = {}; + + /// 있으면 PATCH 본문 · 메일 id 마다 그대로 둘지 묻는다(요청마다 다르게 — 읽음만 실패 등) + bool Function(Map body, String id)? failWhen; + + FakeResponse? mailboxesFailure; + FakeResponse? listFailure; + FakeResponse? patchFailure; + FakeResponse? deletionFailure; + FakeResponse? stateFailure; + + /// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce]) + Completer? listGate; + bool listGateOnce = true; + Completer? patchGate; + Completer? mailboxesGate; + + /// 받은 PATCH /messages 본문 + final List> patches = >[]; + + /// 받은 POST /message-deletions 의 ids + final List> deletions = >[]; + + int get stateRequests => + server.received('GET', MailApiPaths.mailState).length; + + List get listRequests => + server.received('GET', MailApiPaths.messages); + + /// 경로마다 처리기를 건다 + void install() { + server.on('GET', MailApiPaths.mailboxes, (_) async { + await mailboxesGate?.future; + final FakeResponse? failure = mailboxesFailure; + if (failure != null) return failure; + return FakeResponse.ok({ + 'items': mailboxes, + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': mailboxes.length, + }, + }); + }); + server.on('GET', MailApiPaths.messages, (RequestOptions options) async { + final Completer? gate = listGate; + if (gate != null) { + if (listGateOnce) listGate = null; + await gate.future; + } + final FakeResponse? failure = listFailure; + if (failure != null) return failure; + final Map query = options.queryParameters; + final String mailboxId = query['mailboxId']! as String; + if (!mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> all = + messages[mailboxId] ?? >[]; + final int limit = (query['limit'] as int?) ?? kMailPageSize; + final Object? cursor = query['cursor']; + final int start = cursor is String ? int.parse(cursor.substring(2)) : 0; + final int end = (start + limit).clamp(0, all.length); + final bool hasNext = end < all.length; + return FakeResponse.ok({ + 'items': all.sublist(start.clamp(0, all.length), end), + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'c:$end' : null, + 'total': all.length, + }, + }); + }); + server.on('PATCH', MailApiPaths.messages, (RequestOptions options) async { + final Map body = options.data! as Map; + patches.add(body); + await patchGate?.future; + final FakeResponse? failure = patchFailure; + if (failure != null) return failure; + final List ids = (body['ids']! as List).cast(); + final List updated = []; + final List failed = []; + for (final String id in ids) { + if (failIds.contains(id) || + (failWhen?.call(body, id) ?? false) || + _find(id) == null) { + failed.add(id); + continue; + } + _apply(id, body); + updated.add(id); + } + return FakeResponse.ok({ + 'updatedIds': updated, + 'failedIds': failed, + }); + }); + server.on('POST', MailApiPaths.messageDeletions, ( + RequestOptions options, + ) async { + final Map body = options.data! as Map; + final List ids = (body['ids']! as List).cast(); + deletions.add(ids); + final FakeResponse? failure = deletionFailure; + if (failure != null) return failure; + final List deleted = []; + final List skipped = []; + final List> trash = + messages[kTrashId] ?? >[]; + for (final String id in ids) { + final int index = trash.indexWhere( + (Map m) => m['id'] == id, + ); + if (failIds.contains(id) || index == -1) { + skipped.add(id); + continue; + } + trash.removeAt(index); + deleted.add(id); + } + return FakeResponse.ok({ + 'deletedIds': deleted, + 'skippedIds': skipped, + }); + }); + server.on('GET', MailApiPaths.mailState, (_) { + final FakeResponse? failure = stateFailure; + if (failure != null) return failure; + return FakeResponse.ok({'state': state}); + }); + } + + /// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `1` 부터(앞이 최신) + void seed( + String mailboxId, + int count, { + String prefix = 'm', + bool isRead = false, + }) { + messages[mailboxId] = >[ + for (int n = 1; n <= count; n++) + messageJson( + '$prefix$n', + mailboxId: mailboxId, + receivedAt: kTestNow.subtract(Duration(minutes: n)), + isRead: isRead, + ), + ]; + } + + /// 맨 앞에 새 메일을 넣는다(새 메일이 왔다) + void arrive(String mailboxId, Map message) { + (messages[mailboxId] ??= >[]).insert(0, message); + } + + ({String mailboxId, Map message})? _find(String id) { + for (final MapEntry>> entry + in messages.entries) { + for (final Map message in entry.value) { + if (message['id'] == id) { + return (mailboxId: entry.key, message: message); + } + } + } + return null; + } + + void _apply(String id, Map body) { + final ({String mailboxId, Map message})? found = _find(id); + if (found == null) return; + final Map message = found.message; + if (body['isRead'] case final bool isRead) message['isRead'] = isRead; + if (body['isFlagged'] case final bool isFlagged) { + message['isFlagged'] = isFlagged; + } + if (body['mailboxId'] case final String target + when target != found.mailboxId) { + messages[found.mailboxId]!.remove(message); + message['mailboxIds'] = [target]; + final List> into = messages[target] ??= + >[]; + into.add(message); + into.sort( + (Map a, Map b) => + (b['receivedAt']! as String).compareTo(a['receivedAt']! as String), + ); + } + } + + /// [mailboxId] 에 있는 메일 id (차례대로) + List idsIn(String mailboxId) => [ + for (final Map message + in messages[mailboxId] ?? >[]) + message['id']! as String, + ]; +} diff --git a/flutter/test/support/mail_harness.dart b/flutter/test/support/mail_harness.dart new file mode 100644 index 0000000..c87e1c2 --- /dev/null +++ b/flutter/test/support/mail_harness.dart @@ -0,0 +1,78 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/router/app_router.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'fakes.dart'; +import 'mail_fakes.dart'; +import 'provider_harness.dart'; + +/// 시험 직원 (fakes.dart 의 `sessionJson` 과 같다) +const String kMailTestAccount = 'ara.kim@arasystem.it.kr'; + +/// 휴대폰 화면 크기 (보드의 390 × 844) +const Size kPhoneSize = Size(390, 844); + +/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다 +class MailAppHarness { + MailAppHarness(); + + final ProviderHarness base = ProviderHarness(); + late final FakeMailBackend mail = FakeMailBackend(base.server); + + FakeServer get server => base.server; + ManualClock get clock => base.clock; + RecordingNotifier get notifier => base.notifier; + + late ProviderContainer container; + + GoRouter get router => container.read(routerProvider); + + /// 지금 주소 + String location() => + router.routerDelegate.currentConfiguration.uri.toString(); + + /// 앱을 띄운다 — 리프레시 토큰으로 세션을 되살리고 메일 첫 화면(또는 [location])까지 그린다 + Future pump( + WidgetTester tester, { + String? location, + Size size = kPhoneSize, + }) async { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await base.prepare(refreshToken: 'refresh-1'); + server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ); + server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + mail.install(); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + overrides: [...base.overrides], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ); + expect(container.read(mailAccountProvider), kMailTestAccount); + if (location != null) { + router.go(location); + await tester.pumpAndSettle(); + } + } +} -- 2.52.0 From ef23afc94c10bb30b08f25fb410bbca0915c40f8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 19:36:31 +0900 Subject: [PATCH 05/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EA=B5=AC=ED=98=84=ED=95=98?= =?UTF-8?q?=EB=A9=B0=20=EC=A0=95=ED=95=9C=20=EA=B2=83=EC=9D=84=20=EC=95=B1?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20README=20=C2=B7=20flutter=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 디자인 README: 고르기(줄 누르기 · 바뀌는 단추 · 영구 삭제 · 읽음 뒤 끝내기 · 선택 풀기), 붙잡힌 메일, 알림 두 갈래, 서랍, 30일 줄 · [찾기] 준비 중, 새 메일 확인과 병합 규칙(웹과 다른 점), 크기 맞춤, 메일함 이름 차례 - flutter 스킬 골격 표: 메일 목록 · 앱 틀 줄(틀은 Scaffold 가 아니다 · ShellBarHidden · 알림 두 갈래 · 생명주기 안 프로바이더 · 가짜 서버의 한 프레임 응답) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 1 + docs/design/app/README.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 441203f..b23c408 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -24,6 +24,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | | 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | | **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 | +| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 515e24b..31b2066 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -57,6 +57,17 @@ - 당겨서 새로고침. 빈 메일함 · 빈 휴지통(30일 안내 글). - [메일 쓰기]는 넓은 단추로 탭 막대 위(아래에서 88). 알림이 뜨면 함께 올라간다. +- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다) + - 고르기: 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다). 고른 것이 모두 읽음이면 [안 읽음 표시], 모두 중요면 [중요 해제]로 바뀐다. 휴지통에서는 빨간 [영구 삭제](확인 창 — 처음 초점 [취소]), 스팸메일함의 삭제는 휴지통으로 옮기기다. + 읽음 · 중요 표시가 끝나면 보드대로 고르기를 끝낸다(웹은 남긴다) — 통째로 실패하면 고른 것을 되살린다. 다른 메일함 · 다른 탭으로 가면 고른 것을 푼다(계약 2절). + - 붙잡힌 메일(보내기 취소 · 예약)은 고를 수 없다 — 누르면 "고를 수 없습니다" 알림, 화면 낭독기는 꺼진 칸의 까닭을 읽는다. 보낼 시각 + 2초에 다시 보고 남아 있으면 5초마다 세 번 더(서버 시각으로 잰다 · 실패해도 이어 본다). + - 알림: [되돌리기]가 있는 알림은 [메일 쓰기] 바로 아래 한 줄(10초 — 화면 낭독기를 켠 동안은 저절로 닫지 않는다), 단추 없는 알림은 전역 스낵바로 띄워 [되돌리기] 줄을 바꿔 끼우지 않는다. [메일 쓰기]는 알림이 뜨면 함께 올라간다. + - 서랍은 메일함을 바꿔 끼운다(방문 기록을 쌓지 않는다 — 다른 메일함은 받은편지함 위에 하나만). 단추로만 연다(가장자리 밀기 없음). 메일함 목록을 받기 전에는 뼈대, 받지 못했으면 [다시 시도]. + - 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 아직 "메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요." + - 새 메일 확인: 화면이 보이고(다른 탭 · 전체 화면에 가리지 않음) 앱이 앞에 있을 때만 1분마다, 다시 보이면 10초가 지났을 때 바로(웹 3단계). 가려진 목록은 바뀜을 적어 두었다가 보일 때 맞춘다. + 다시 받은 쪽이 메일함 끝까지면 그 밖의 옛 메일은 다른 곳에서 지운 것으로 보고 남기지 않는다(웹은 남긴다 — 서버의 hasNext 는 끝까지 받았을 때만 거짓이다). 끝 표시는 보이는 동안 다음 쪽을 이어 부르고, 같은 커서가 되풀이되면 [다시 시도]로 멈춘다. + - 크기: 탭 막대의 고른 탭 알약은 Material 3 그대로 64×32(보드 56×32), 글자는 테마 역할로 맞췄다(12.5 → 12 · 13.5 → 13 · 14.5 → 15 · 17 → 18, 굵기 650 → 700 · 450 → 500). 탭 막대는 위 안전 영역을 빼고, 고르는 중 알림 줄은 시스템 내비게이션 위에 뜬다. + - 내 메일함 이름의 차례는 대소문자를 가리지 않는 비교다 — 영문 · 한글이 섞인 이름은 웹(`localeCompare('ko')`)과 다를 수 있다. ### 읽기 (`App1Reader`) - 위 막대: 뒤로 · 삭제 · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고). 아래 행동 막대: 답장 · 전체 답장 · 전달. - 머리(제목 · 보낸 사람 · 받는 사람 펼치기 · 시각)는 앱 화면, 본문은 앱 안 WebView — 스크립트를 끄고 앱이 감싼 문서의 CSP 로 바깥에 나가지 않는다. 외부 이미지는 안내 줄의 [이미지 보기]를 누르면 앱이 받아서 넣는다. 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면. -- 2.52.0 From a37045a553d1d8aa62da02e5c288048c074bf255 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 22:26:09 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4=20(App1Reader)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로 보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고, 외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이 WebView 에 가지 않는다). - 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 · 예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다 - 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW 로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg · 설치 파일 · 인증서는 받지 않는다 - 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고, 뜻밖의 실패에도 handler.next 로 끝낸다 - 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 — 문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다 - 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩 비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run - 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 · html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은 lock 에 있던 판을 직접 의존으로 Co-Authored-By: Claude Opus 5.5 --- flutter/README.md | 13 +- .../android/app/src/main/AndroidManifest.xml | 13 + .../kr/it/arasystem/app/MainActivity.kt | 91 +- .../main/res/xml/downloaded_file_paths.xml | 10 + .../lib/core/api/response_interceptor.dart | 70 +- flutter/lib/core/files/downloaded_files.dart | 215 +++ flutter/lib/core/files/file_names.dart | 71 + flutter/lib/core/files/file_types.dart | 95 ++ flutter/lib/core/theme/app_spacing.dart | 22 + flutter/lib/core/utils/file_size.dart | 33 + flutter/lib/core/utils/korean_format.dart | 12 + .../mail/data/mail_link_launcher.dart | 20 + .../mail/data/models/message_detail.dart | 267 ++++ .../data/repositories/mail_repository.dart | 173 ++- .../features/mail/domain/compose_request.dart | 200 +++ .../mail/domain/mail_body_notices.dart | 32 + .../lib/features/mail/domain/mail_html.dart | 503 +++++++ .../lib/features/mail/domain/mail_images.dart | 71 + .../lib/features/mail/domain/mail_links.dart | 79 ++ flutter/lib/features/mail/mail_routes.dart | 38 +- .../mail/presentation/mail_texts.dart | 146 ++- .../presentation/message_reader_view.dart | 67 + .../pages/message_reader_page.dart | 506 +++++++ .../providers/attachment_downloads.dart | 286 ++++ .../providers/mail_list_controller.dart | 39 + .../providers/mail_state_watcher.dart | 45 +- .../providers/message_reader_controller.dart | 771 +++++++++++ .../providers/message_reader_state.dart | 82 ++ .../presentation/widgets/mail_body_view.dart | 135 ++ .../widgets/mail_list_states.dart | 8 +- .../widgets/reader_attachments.dart | 282 ++++ .../presentation/widgets/reader_bars.dart | 253 ++++ .../presentation/widgets/reader_header.dart | 342 +++++ .../presentation/widgets/reader_notices.dart | 195 +++ flutter/lib/main.dart | 8 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + flutter/pubspec.lock | 52 +- flutter/pubspec.yaml | 16 + flutter/test/app_test.dart | 9 +- .../core/files/downloaded_files_test.dart | 94 ++ flutter/test/core/files/file_types_test.dart | 81 ++ .../test/features/mail/mail_html_test.dart | 411 ++++++ .../features/mail/mail_list_page_test.dart | 27 +- .../features/mail/mail_reader_rules_test.dart | 506 +++++++ .../features/mail/mail_repository_test.dart | 296 +++++ .../mail/message_reader_page_test.dart | 1157 +++++++++++++++++ flutter/test/support/fake_webview.dart | 134 ++ flutter/test/support/fakes.dart | 92 +- flutter/test/support/file_fakes.dart | 96 ++ flutter/test/support/mail_fakes.dart | 181 +++ flutter/test/support/mail_harness.dart | 26 +- 51 files changed, 8325 insertions(+), 48 deletions(-) create mode 100644 flutter/android/app/src/main/res/xml/downloaded_file_paths.xml create mode 100644 flutter/lib/core/files/downloaded_files.dart create mode 100644 flutter/lib/core/files/file_names.dart create mode 100644 flutter/lib/core/files/file_types.dart create mode 100644 flutter/lib/core/utils/file_size.dart create mode 100644 flutter/lib/features/mail/data/mail_link_launcher.dart create mode 100644 flutter/lib/features/mail/data/models/message_detail.dart create mode 100644 flutter/lib/features/mail/domain/compose_request.dart create mode 100644 flutter/lib/features/mail/domain/mail_body_notices.dart create mode 100644 flutter/lib/features/mail/domain/mail_html.dart create mode 100644 flutter/lib/features/mail/domain/mail_images.dart create mode 100644 flutter/lib/features/mail/domain/mail_links.dart create mode 100644 flutter/lib/features/mail/presentation/message_reader_view.dart create mode 100644 flutter/lib/features/mail/presentation/pages/message_reader_page.dart create mode 100644 flutter/lib/features/mail/presentation/providers/attachment_downloads.dart create mode 100644 flutter/lib/features/mail/presentation/providers/message_reader_controller.dart create mode 100644 flutter/lib/features/mail/presentation/providers/message_reader_state.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_body_view.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/reader_attachments.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/reader_bars.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/reader_header.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/reader_notices.dart create mode 100644 flutter/test/core/files/downloaded_files_test.dart create mode 100644 flutter/test/core/files/file_types_test.dart create mode 100644 flutter/test/features/mail/mail_html_test.dart create mode 100644 flutter/test/features/mail/mail_reader_rules_test.dart create mode 100644 flutter/test/features/mail/message_reader_page_test.dart create mode 100644 flutter/test/support/fake_webview.dart create mode 100644 flutter/test/support/file_fakes.dart diff --git a/flutter/README.md b/flutter/README.md index c4e1389..b0f55a4 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -56,13 +56,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 - **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다. - **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`). - 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다. +- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고, + 스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 — + 아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다. + 문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다. +- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널). + 앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`). + 열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`). + 고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다. ## 나중에 넣을 때 주의 - **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다. -- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다). - 받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다). +- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다 + (`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다). + 받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). ## 라이선스 diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 0773065..1030971 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,19 @@ + + + + + when (call.method) { + OPEN_METHOD -> result.success( + openFile(call.argument("path"), call.argument("type")), + ) + else -> result.notImplemented() + } + } + } + + private fun openFile(path: String?, type: String?): String { + if (path == null) return RESULT_FAILED + val file: File + val root: File + try { + root = File(cacheDir, DOWNLOADS_DIR).canonicalFile + file = File(path).canonicalFile + } catch (error: IOException) { + return RESULT_FAILED + } + // 받은 파일 폴더 밖은 열지 않는다 (../ · 심볼릭 링크로 앱의 다른 파일을 건네지 않게) + if (!file.path.startsWith(root.path + File.separator)) return RESULT_FAILED + if (!file.isFile) return RESULT_NOT_FOUND + val uri = try { + FileProvider.getUriForFile(this, packageName + AUTHORITY_SUFFIX, file) + } catch (error: IllegalArgumentException) { + return RESULT_FAILED + } + // 고르는 창(createChooser)은 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeTypeOf(type)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity(intent) + RESULT_OPENED + } catch (error: ActivityNotFoundException) { + RESULT_NO_APP + } catch (error: SecurityException) { + RESULT_FAILED + } + } + + /** + * 넘겨받은 형식 — Dart 가 정한다(`core/files/file_types.dart` 의 `fileOpenTypeOf` — 확장자에서 먼저, 열지 않는 형식은 받기 전에 막는다). + * 여기서는 모양만 다시 본다 — 틀리면 아무 형식(octet-stream) + */ + private fun mimeTypeOf(type: String?): String { + val given = type?.trim()?.lowercase() + return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE + } + + private companion object { + /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */ + const val FILES_CHANNEL = "kr.it.arasystem.app/files" + const val OPEN_METHOD = "open" + + /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */ + const val DOWNLOADS_DIR = "downloads" + + /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */ + const val AUTHORITY_SUFFIX = ".downloads" + + /** 짝: Dart `kGenericFileType` · `_typePattern`(`core/files/file_types.dart`) */ + const val GENERIC_TYPE = "application/octet-stream" + val MIME_PATTERN = Regex("^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$") + + const val RESULT_OPENED = "opened" + const val RESULT_NO_APP = "noApp" + const val RESULT_NOT_FOUND = "notFound" + const val RESULT_FAILED = "failed" + } +} diff --git a/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml new file mode 100644 index 0000000..437824e --- /dev/null +++ b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/flutter/lib/core/api/response_interceptor.dart b/flutter/lib/core/api/response_interceptor.dart index 114dcaf..2aafc45 100644 --- a/flutter/lib/core/api/response_interceptor.dart +++ b/flutter/lib/core/api/response_interceptor.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:dio/dio.dart'; @@ -41,12 +44,75 @@ class ResponseInterceptor extends Interceptor { ); } + /// 실패 — 코드를 정한다. 바이트 · 스트림 응답(첨부 받기 · 그림)의 실패 본문은 JSON 으로 풀리지 않은 채 오므로 + /// 먼저 읽어(작다 — [kMaxErrorBodyBytes] 까지) 표준 실패의 `error.code` 를 꺼낸다. 성공 본문은 건드리지 않는다. + /// + /// 무슨 일이 있어도 `handler.next` 로 끝낸다(인증 인터셉터와 같은 규칙) — 비동기 처리기가 던지면 요청이 끝나지 않고 매달린다 @override - void onError(DioException err, ErrorInterceptorHandler handler) { + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { if (err.error is ApiException) { handler.next(err); return; } - handler.next(err.copyWith(error: toApiException(err))); + DioException failure; + try { + final Response? response = err.response; + if (response != null) { + final Map? decoded = await decodeRawErrorBody( + response.data, + ); + if (decoded != null) response.data = decoded; + } + failure = err.copyWith(error: toApiException(err)); + } on Object { + // 실패 본문을 읽다 뜻밖에 실패했다 — 코드는 최종 폴백으로 정하고 다음 인터셉터로 넘긴다 + failure = err.copyWith( + error: ApiException.fromCode( + kFallbackCode, + statusCode: err.response?.statusCode, + ), + ); + } + handler.next(failure); } } + +/// 바이트 · 스트림 응답의 실패 본문을 읽는 최대 크기 — 표준 실패 본문은 1KB 안팎이다 +const int kMaxErrorBodyBytes = 64 * 1024; + +/// 바이트(`List`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null. +/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다 +Future?> decodeRawErrorBody(Object? data) async { + final List? bytes; + if (data is ResponseBody) { + bytes = await _readLimited(data.stream); + } else if (data is List) { + bytes = data.length <= kMaxErrorBodyBytes ? data : null; + } else { + return null; + } + if (bytes == null || bytes.isEmpty) return null; + try { + final Object? json = jsonDecode(utf8.decode(bytes, allowMalformed: true)); + return json is Map ? json : null; + } on FormatException { + return null; + } +} + +Future?> _readLimited(Stream> stream) async { + final BytesBuilder builder = BytesBuilder(copy: false); + try { + await for (final List chunk in stream) { + builder.add(chunk); + // 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다 + if (builder.length > kMaxErrorBodyBytes) return null; + } + } on Object { + return null; + } + return builder.takeBytes(); +} diff --git a/flutter/lib/core/files/downloaded_files.dart b/flutter/lib/core/files/downloaded_files.dart new file mode 100644 index 0000000..1d7fa90 --- /dev/null +++ b/flutter/lib/core/files/downloaded_files.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 받은 파일을 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `android/app/src/main/res/xml/downloaded_file_paths.xml` 의 `cache-path` 와 `MainActivity.kt` 의 `DOWNLOADS_DIR`** — 바꾸면 셋 다 +const String kDownloadedFilesFolder = 'downloads'; + +/// 받은 파일 하나의 자리 — 받는 동안 조각을 이어 쓰고, 다 받으면 닫는다 +abstract class DownloadTarget { + /// 기기 안의 경로 — 다른 앱으로 열 때 쓴다(로그 · 알림에 싣지 않는다) + String get path; + + /// 받은 조각을 이어 쓴다 + void add(List chunk); + + /// 다 받았다 — 쓴 것을 마저 내보내고 닫는다 + Future close(); + + /// 받다 말았다(실패 · 취소) — 닫고 지운다 + Future discard(); +} + +/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통). +/// +/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) · +/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다. +abstract class DownloadedFileStore { + /// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다 + Future create(String fileName); + + /// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래 +final Provider downloadedFileStoreProvider = + Provider((Ref ref) => CacheDirFileStore()); + +/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore]. +/// +/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게. +class CacheDirFileStore implements DownloadedFileStore { + CacheDirFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + Random? random, + }) : _baseDirectory = baseDirectory, + _random = random ?? Random.secure(); + + final Future Function() _baseDirectory; + final Random _random; + Future _queue = Future.value(); + + /// 받을 때마다 만드는 폴더 이름의 길이(16진수) + static const int _folderNameLength = 16; + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException]) + Future _root() async { + final Directory base; + try { + base = await _baseDirectory(); + } on FileSystemException { + rethrow; + } on Exception { + throw const FileSystemException('임시 폴더를 찾지 못했습니다'); + } + return Directory('${base.path}/$kDownloadedFilesFolder'); + } + + @override + Future create(String fileName) => _serial(() async { + final Directory root = await _root(); + final Directory folder = Directory('${root.path}/${_folderName()}'); + await folder.create(recursive: true); + final File file = File('${folder.path}/${safeFileName(fileName)}'); + return _FileTarget(file, file.openWrite()); + }); + + @override + Future exists(String path) => File(path).exists(); + + @override + Future purge() => _serial(() async { + try { + final Directory root = await _root(); + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다 + } + }); + + String _folderName() => List.generate( + _folderNameLength, + (_) => _random.nextInt(16).toRadixString(16), + ).join(); +} + +class _FileTarget implements DownloadTarget { + _FileTarget(this._file, this._sink) { + // 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게 + unawaited(_sink.done.then((_) {}, onError: (Object _) {})); + } + + final File _file; + final IOSink _sink; + bool _closed = false; + + @override + String get path => _file.path; + + @override + void add(List chunk) => _sink.add(chunk); + + /// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다. + /// close 는 그 실패를 [FileSystemException] 으로 돌려준다) + @override + Future close() async { + if (_closed) return; + _closed = true; + await _sink.close(); + } + + @override + Future discard() async { + if (!_closed) { + _closed = true; + try { + await _sink.close(); + } on FileSystemException { + // 지우는 것이 목적이다 + } + } + try { + await _file.parent.delete(recursive: true); + } on FileSystemException { + // 이미 없거나 다른 앱이 열었다 — 다음 정리 · 기기가 지운다 + } + } +} + +/// 받은 파일을 다른 앱으로 연 결과 +enum FileOpenResult { + /// 다른 앱을 띄웠다(또는 고르는 창) + opened, + + /// 이 형식을 열 앱이 없다 + noApp, + + /// 파일이 없다(기기가 임시 폴더를 비웠다) + notFound, + + /// 그 밖의 실패(이 기기에서 열 수 없음 — iOS 는 아직 없다) + failed, +} + +/// 받은 파일을 다른 앱으로 연다 — 시험은 덮어쓴다 +abstract class FileOpener { + Future open({required String path, required String mimeType}); +} + +final Provider fileOpenerProvider = Provider( + (Ref ref) => const PlatformFileOpener(), +); + +/// 안드로이드 `ACTION_VIEW`(`MainActivity.kt` 의 파일 채널) — 앱의 FileProvider 가 [kDownloadedFilesFolder] 안의 파일만 건넨다. +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭: 파일 여는 플러그인은 앱에 저장소 · 사진 권한(Play 의 사진 · 동영상 권한 정책)과 +/// 기기 전체를 가리키는 FileProvider 경로를 더하고, 공유(share) 플러그인은 "열기"가 아니라 "보내기" 창이다. +/// 이 채널은 받은 파일 폴더 한 곳만 읽게 건넨다(읽기 권한 1회 — `FLAG_GRANT_READ_URI_PERMISSION`). iOS 는 붙일 때 만든다 +class PlatformFileOpener implements FileOpener { + const PlatformFileOpener(); + + /// 짝: `MainActivity.kt` 의 `FILES_CHANNEL` · `OPEN_METHOD` + static const MethodChannel channel = MethodChannel( + 'kr.it.arasystem.app/files', + ); + static const String openMethod = 'open'; + + @override + Future open({ + required String path, + required String mimeType, + }) async { + try { + final String? result = await channel.invokeMethod( + openMethod, + {'path': path, 'type': mimeType}, + ); + return switch (result) { + 'opened' => FileOpenResult.opened, + 'noApp' => FileOpenResult.noApp, + 'notFound' => FileOpenResult.notFound, + _ => FileOpenResult.failed, + }; + } on PlatformException { + return FileOpenResult.failed; + } on MissingPluginException { + return FileOpenResult.failed; + } + } +} diff --git a/flutter/lib/core/files/file_names.dart b/flutter/lib/core/files/file_names.dart new file mode 100644 index 0000000..946d263 --- /dev/null +++ b/flutter/lib/core/files/file_names.dart @@ -0,0 +1,71 @@ +/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다 +/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다. +library; + +import 'dart:convert'; + +/// 이름이 비면 쓰는 이름 +const String kFallbackFileName = '첨부파일'; + +/// 저장 이름의 최대 UTF-8 바이트 — 안드로이드 파일 시스템(ext4 · f2fs)의 한 칸 한도(255)보다 넉넉히 작게 +const int kMaxFileNameBytes = 200; + +/// 남기는 확장자의 최대 길이 (점 뒤) +const int _maxExtensionLength = 16; + +/// 지우는 글자 — 제어 문자(C0 · DEL · C1) +bool _isControl(int rune) => rune <= 0x1F || (rune >= 0x7F && rune <= 0x9F); + +/// 지우는 글자 — 방향 제어 · 보이지 않는 글자(오른쪽에서 왼쪽 표시로 "…fdp.exe" 를 "…exe.pdf" 처럼 보이게 하는 속임 이름) +bool _isInvisibleFormat(int rune) => + rune == 0x061C || + (rune >= 0x200B && rune <= 0x200F) || + (rune >= 0x202A && rune <= 0x202E) || + (rune >= 0x2060 && rune <= 0x2069) || + rune == 0xFEFF; + +/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자 +const String _replacedCharacters = r'/\:*?"<>|'; + +/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback] +String safeFileName(String name, {String fallback = kFallbackFileName}) { + final StringBuffer cleaned = StringBuffer(); + for (final int rune in name.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + final String char = String.fromCharCode(rune); + cleaned.write(_replacedCharacters.contains(char) ? '_' : char); + } + // 앞뒤 공백 · 점을 지운다 — 앞의 점은 숨은 파일, 끝의 점 · 공백은 다른 시스템이 받지 않는다 + String result = cleaned.toString().trim(); + result = result.replaceAll(RegExp(r'^[.\s]+|[.\s]+$'), ''); + if (result.isEmpty) return fallback; + return _fitBytes(result, kMaxFileNameBytes); +} + +/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 +String _fitBytes(String name, int maxBytes) { + if (utf8.encode(name).length <= maxBytes) return name; + final int dot = name.lastIndexOf('.'); + final String extension = + dot > 0 && + name.length - dot - 1 <= _maxExtensionLength && + RegExp(r'^[A-Za-z0-9]+$').hasMatch(name.substring(dot + 1)) + ? name.substring(dot) + : ''; + final String base = extension.isEmpty ? name : name.substring(0, dot); + final int budget = maxBytes - utf8.encode(extension).length; + final StringBuffer kept = StringBuffer(); + int used = 0; + for (final int rune in base.runes) { + final int size = utf8.encode(String.fromCharCode(rune)).length; + if (used + size > budget) break; + kept.writeCharCode(rune); + used += size; + } + final String trimmed = kept.toString().trimRight(); + return trimmed.isEmpty + ? '$kFallbackFileName$extension' + : '$trimmed$extension'; +} diff --git a/flutter/lib/core/files/file_types.dart b/flutter/lib/core/files/file_types.dart new file mode 100644 index 0000000..794a34e --- /dev/null +++ b/flutter/lib/core/files/file_types.dart @@ -0,0 +1,95 @@ +/// 받은 파일을 다른 앱으로 열 때의 형식(MIME)과 앱에서 열지 않는 형식 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 형식은 보낸 쪽이 정한 글이라 확장자보다 믿지 않는다 — 열 형식은 정리한 이름([safeFileName])의 확장자에서 먼저 정하고 +/// (`package:mime` 의 표), 확장자를 모를 때만 서버가 알려 준 형식(첨부의 `type`)을 쓴다. 안드로이드는 이 형식으로 열 앱을 고른다. +/// +/// 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 앱에서 열지 않는다 — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나 +/// 기기에 설치 · 등록하는 창을 띄우지 않게. 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 걸리면 받지도 않는다. +/// 고르는 창(`Intent.createChooser`)으로 여는 길도 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 +library; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:mime/mime.dart'; + +/// 형식을 모를 때 — 안드로이드는 이 형식을 받는 앱이 드물다(열 앱이 없다고 알린다) +const String kGenericFileType = 'application/octet-stream'; + +/// 앱에서 열지 않는 확장자 (소문자 — 정리한 이름의 마지막 점 뒤) +const Set kBlockedOpenExtensions = { + // 웹 문서 — 브라우저가 열면 스크립트가 돈다 + 'html', 'htm', 'xhtml', 'xht', 'shtml', 'mht', 'mhtml', // + // SVG — 그림이지만 스크립트를 담는다 + 'svg', 'svgz', // + // 설치 파일 + 'apk', 'apks', 'xapk', // + // 인증서 · 키 묶음 — 열면 기기에 설치하는 창이 뜬다 + 'p12', 'pfx', 'cer', 'crt', 'der', 'pem', 'p7b', 'p7c', +}; + +/// 앱에서 열지 않는 형식 — 위 확장자에서 나오는 형식(`package:mime` 의 표 — 시험이 빠짐없이 맞춰 본다)과 +/// 서버 · 보낸 쪽이 같은 갈래에 흔히 쓰는 형식 +const Set kBlockedOpenTypes = { + // 웹 문서 · MHTML + 'text/html', 'application/xhtml+xml', 'multipart/related', // + 'application/x-mimearchive', // + // SVG + 'image/svg+xml', // + // 설치 파일 + 'application/vnd.android.package-archive', // + // 인증서 · 키 묶음 + 'application/x-pkcs12', 'application/pkcs12', 'application/pkix-cert', // + 'application/x-x509-ca-cert', 'application/x-x509-user-cert', // + 'application/x-x509-server-cert', 'application/x-pem-file', // + 'application/x-pkcs7-certificates', 'application/pkcs7-mime', // + 'application/x-pkcs7-mime', 'application/x-pkcs7-certreqresp', +}; + +/// 형식 글의 모양 — `종류/이름` (짝: `MainActivity.kt` 의 `MIME_PATTERN` — 앱도 넘겨받은 형식을 이 모양으로 다시 본다) +final RegExp _typePattern = RegExp( + r'^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$', +); + +/// 정리한 이름의 확장자 — 소문자, 없으면 빈 글 +String fileExtensionOf(String fileName) { + final String name = safeFileName(fileName); + final int dot = name.lastIndexOf('.'); + if (dot <= 0 || dot == name.length - 1) return ''; + return name.substring(dot + 1).toLowerCase(); +} + +/// 서버가 알려 준 형식을 비교할 꼴로 — 소문자, `;` 뒤의 매개변수(`charset=` 등)를 뗀다. 모양이 틀리면 null +String? normalizedFileType(String? type) { + if (type == null) return null; + final int semicolon = type.indexOf(';'); + final String value = (semicolon == -1 ? type : type.substring(0, semicolon)) + .trim() + .toLowerCase(); + return _typePattern.hasMatch(value) ? value : null; +} + +/// 확장자에서 나오는 형식 — 모르거나 아무 형식(octet-stream)이면 null +String? fileTypeOfExtension(String extension) { + if (extension.isEmpty) return null; + final String? type = lookupMimeType('file.$extension'); + return type == null || type == kGenericFileType ? null : type; +} + +/// 다른 앱으로 열 때 넘길 형식 — 확장자에서 먼저, 모르면 서버 형식(모양이 맞고 아무 형식이 아닐 때), 그것도 아니면 [kGenericFileType] +String fileOpenTypeOf(String fileName, String? serverType) { + final String? byExtension = fileTypeOfExtension(fileExtensionOf(fileName)); + if (byExtension != null) return byExtension; + final String? given = normalizedFileType(serverType); + return given ?? kGenericFileType; +} + +/// 앱에서 열지 않는 파일인가 — 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 [kBlockedOpenExtensions] · [kBlockedOpenTypes] 에 걸리면 +bool isBlockedFileOpen(String fileName, String? serverType) { + final String extension = fileExtensionOf(fileName); + if (kBlockedOpenExtensions.contains(extension)) return true; + final String? byExtension = fileTypeOfExtension(extension); + if (byExtension != null && kBlockedOpenTypes.contains(byExtension)) { + return true; + } + final String? given = normalizedFileType(serverType); + return given != null && kBlockedOpenTypes.contains(given); +} diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 0bd4d15..853fe7a 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -120,4 +120,26 @@ abstract final class AppSize { /// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다 static const double fabHeight = 56; + + // ── 앱 보드에서 정한 크기 (`App1Reader` — 2026-11-05 확정) ── + /// 읽기 화면의 위 막대 높이 — 뒤로 · 삭제 · 더 보기 + static const double readerBarHeight = 56; + + /// 읽기 화면의 아래 행동 막대 높이 — 답장 · 전체 답장 · 전달 + static const double readerActionBarHeight = 64; + + /// 첨부 줄의 최소 높이 + static const double attachmentRowMinHeight = 64; + + /// 첨부 줄의 파일 아이콘 칸 + static const double fileIconBox = 40; + + /// 첨부를 받는 동안의 진행 막대 두께 + static const double progressBarHeight = 4; + + /// 머리 펼침의 칸 이름 열 폭 ("보낸 사람" · "받는 사람" · "숨은참조") + static const double detailsLabelWidth = 64; + + /// 더 보기 메뉴의 폭 + static const double readerMenuWidth = 224; } diff --git a/flutter/lib/core/utils/file_size.dart b/flutter/lib/core/utils/file_size.dart new file mode 100644 index 0000000..1652191 --- /dev/null +++ b/flutter/lib/core/utils/file_size.dart @@ -0,0 +1,33 @@ +/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수). +/// +/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. +library; + +const int _bytesPerUnit = 1024; +const List _units = ['KB', 'MB', 'GB', 'TB']; +const int _decimalThreshold = 10; + +/// 크기 표기 — 음수면 빈 글 +String formatFileSize(int bytes) { + if (bytes < 0) return ''; + if (bytes < _bytesPerUnit) return '$bytes B'; + double value = bytes / _bytesPerUnit; + int unit = 0; + // 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다 + while (value.round() >= _bytesPerUnit && unit < _units.length - 1) { + value /= _bytesPerUnit; + unit++; + } + final String shown = unit > 0 && value < _decimalThreshold + ? _oneDecimal(value) + : value.round().toString(); + return '$shown ${_units[unit]}'; +} + +/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다) +String _oneDecimal(double value) { + final double rounded = (value * 10).round() / 10; + return rounded == rounded.roundToDouble() + ? rounded.round().toString() + : rounded.toStringAsFixed(1); +} diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart index 1152fba..76493e0 100644 --- a/flutter/lib/core/utils/korean_format.dart +++ b/flutter/lib/core/utils/korean_format.dart @@ -37,6 +37,18 @@ String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일'; String koreanNumericDate(DateTime local) => '${local.year}. ${local.month}. ${local.day}.'; +/// 요일 한 글자 — 월 · 화 · … · 일 (웹 `{ weekday: 'short' }`) +String koreanWeekday(DateTime local) => + const ['월', '화', '수', '목', '금', '토', '일'][local.weekday - 1]; + +/// 달 · 날 · 요일 · 시각 — "11월 7일 (토) 오전 9:00" (웹 `formatScheduleTime` — 예약 시각. 7일 안이라 연도가 없다) +String koreanScheduleTime(DateTime local) => + '${koreanMonthDay(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}'; + +/// 연도까지 — "2026년 11월 4일 (수) 오후 4:20" (앱 보드 `App1Reader` 의 머리 펼침 "날짜") +String koreanFullDateTime(DateTime local) => + '${local.year}년 ${koreanScheduleTime(local)}'; + /// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값) bool isSameCalendarDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; diff --git a/flutter/lib/features/mail/data/mail_link_launcher.dart b/flutter/lib/features/mail/data/mail_link_launcher.dart new file mode 100644 index 0000000..644b1f0 --- /dev/null +++ b/flutter/lib/features/mail/data/mail_link_launcher.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다 +typedef MailLinkLauncher = Future Function(Uri uri); + +final Provider mailLinkLauncherProvider = + Provider((Ref ref) => openMailLinkExternally); + +/// 바깥 앱으로 연다 — 열었으면 true. +/// +/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는 +/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다) +Future openMailLinkExternally(Uri uri) async { + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object { + return false; + } +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart new file mode 100644 index 0000000..46f1e39 --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -0,0 +1,267 @@ +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:flutter/foundation.dart'; + +/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment` +@immutable +class MessageAttachment { + const MessageAttachment({ + required this.id, + required this.name, + required this.type, + required this.size, + this.isInline = false, + }); + + final String id; + + /// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`) + final String name; + + /// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다 + final String type; + + /// 바이트 + final int size; + + /// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다 + final bool isInline; + + /// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다 + static MessageAttachment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? type = json['type']; + final Object? size = json['size']; + final Object? isInline = json['isInline']; + if (id is! String || + id.isEmpty || + name is! String || + type is! String || + size is! num || + (isInline != null && isInline is! bool)) { + return null; + } + return MessageAttachment( + id: id, + name: name, + type: type, + size: size.toInt(), + isInline: isInline == true, + ); + } + + @override + bool operator ==(Object other) => + other is MessageAttachment && + other.id == id && + other.name == name && + other.type == type && + other.size == size && + other.isInline == isInline; + + @override + int get hashCode => Object.hash(id, name, type, size, isInline); +} + +/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다) +@immutable +class MessageLargeFile { + const MessageLargeFile({ + required this.fileId, + required this.name, + required this.size, + }); + + final String fileId; + + /// 서버 기록의 이름 + final String name; + + /// 바이트 + final int size; + + static MessageLargeFile? tryParse(Object? json) { + if (json is! Map) return null; + final Object? fileId = json['fileId']; + final Object? name = json['name']; + final Object? size = json['size']; + if (fileId is! String || name is! String || size is! num) return null; + return MessageLargeFile(fileId: fileId, name: name, size: size.toInt()); + } + + @override + bool operator ==(Object other) => + other is MessageLargeFile && + other.fileId == fileId && + other.name == name && + other.size == size; + + @override + int get hashCode => Object.hash(fileId, name, size); +} + +/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`) +/// +/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` · +/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다. +@immutable +class MessageDetail { + const MessageDetail({ + required this.summary, + this.cc = const [], + this.bcc = const [], + this.sentAt, + this.htmlBody, + this.textBody, + this.hasBlockedImages = false, + this.imagesShown = false, + this.isHtmlBodyOmitted = false, + this.isBodyTruncated = false, + this.attachments = const [], + this.largeFiles = const [], + }); + + /// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송 + final MessageSummary summary; + final List cc; + + /// 숨은참조 — 내가 보낸 메일에만 온다 + final List bcc; + final DateTime? sentAt; + + /// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`) + final String? htmlBody; + final String? textBody; + + /// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄 + final bool hasBlockedImages; + + /// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다 + final bool imagesShown; + + /// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다 + final bool isHtmlBodyOmitted; + + /// 보여 줄 본문이 읽기 한도에서 잘렸다 + final bool isBodyTruncated; + final List attachments; + final List largeFiles; + + String get id => summary.id; + + /// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`) + List get listedAttachments => [ + for (final MessageAttachment attachment in attachments) + if (!attachment.isInline) attachment, + ]; + + /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로) + MessageDetail withSummary(MessageSummary next) => MessageDetail( + summary: next, + cc: cc, + bcc: bcc, + sentAt: sentAt, + htmlBody: htmlBody, + textBody: textBody, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: isHtmlBodyOmitted, + isBodyTruncated: isBodyTruncated, + attachments: attachments, + largeFiles: largeFiles, + ); + + /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`) + MessageDetail withBodyOf(MessageDetail other) => MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + sentAt: sentAt, + htmlBody: other.htmlBody, + textBody: other.textBody, + hasBlockedImages: other.hasBlockedImages, + imagesShown: other.imagesShown, + isHtmlBodyOmitted: other.isHtmlBodyOmitted, + isBodyTruncated: other.isBodyTruncated, + attachments: other.attachments, + largeFiles: other.largeFiles, + ); + + /// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다 + static MessageDetail? tryParse(Object? json) { + if (json is! Map) return null; + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null) return null; + final List? cc = _addresses(json['cc']); + final List? bcc = _addresses(json['bcc']); + final Object? sentAt = json['sentAt']; + final Object? htmlBody = json['htmlBody']; + final Object? textBody = json['textBody']; + final List? attachments = _attachments( + json['attachments'], + ); + final bool? hasBlockedImages = _flag(json['hasBlockedImages']); + final bool? imagesShown = _flag(json['imagesShown']); + final bool? omitted = _flag(json['isHtmlBodyOmitted']); + final bool? truncated = _flag(json['isBodyTruncated']); + final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null; + if (cc == null || + bcc == null || + (sentAt != null && sent == null) || + (htmlBody != null && htmlBody is! String) || + (textBody != null && textBody is! String) || + attachments == null || + hasBlockedImages == null || + imagesShown == null || + omitted == null || + truncated == null) { + return null; + } + return MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + sentAt: sent?.toUtc(), + htmlBody: htmlBody as String?, + textBody: textBody as String?, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: omitted, + isBodyTruncated: truncated, + attachments: attachments, + largeFiles: _largeFiles(json['largeFiles']), + ); + } + + /// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null + static List? _addresses(Object? value) => + value == null ? const [] : MailAddress.tryParseList(value); + + /// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null + static bool? _flag(Object? value) { + if (value == null) return false; + return value is bool ? value : null; + } + + static List? _attachments(Object? value) { + if (value == null) return const []; + if (value is! List) return null; + final List items = []; + for (final Object? item in value) { + final MessageAttachment? attachment = MessageAttachment.tryParse(item); + if (attachment == null) return null; + items.add(attachment); + } + return List.unmodifiable(items); + } + + /// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다) + static List _largeFiles(Object? value) { + if (value is! List) return const []; + return List.unmodifiable([ + for (final Object? item in value) ?MessageLargeFile.tryParse(item), + ]); + } +} diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index d01397e..9e71c72 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -1,9 +1,15 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/api/server_time.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:dio/dio.dart'; @@ -20,8 +26,24 @@ abstract final class MailApiPaths { /// 새 메일 확인 — 불투명한 상태 글 static const String mailState = '/mail-state'; + + /// 첨부 받기 — 표준 래퍼 없는 파일 + static String attachment(String messageId, String attachmentId) => + '${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}'; + + /// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계) + static String scheduledMessage(String id) => + '/scheduled-messages/${Uri.encodeComponent(id)}'; + + /// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계) + static String mailImage(String token) => + '/mail-images/${Uri.encodeComponent(token)}'; } +/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기") +const String kMailImagesQuery = 'images'; +const String kMailImagesShow = 'show'; + /// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다 const int kMailPageSize = 20; @@ -76,14 +98,35 @@ class MailRepository { return page.withServerTime(serverTimeOf(response)); } - /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다) + /// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`). + /// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다 + Future<({MessageDetail detail, DateTime? serverTime})> getMessage( + String id, { + bool showImages = false, + bool silent = true, + }) async { + final Response response = await _dio.get( + MailApiPaths.message(id), + queryParameters: showImages + ? {kMailImagesQuery: kMailImagesShow} + : null, + options: silent ? ApiOptions.silent() : null, + ); + final MessageDetail? detail = MessageDetail.tryParse(response.data); + if (detail == null) throw _malformed(response); + return (detail: detail, serverTime: serverTimeOf(response)); + } + + /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리) Future updateMessage( String id, - MessageChanges changes, - ) async { + MessageChanges changes, { + bool silent = false, + }) async { final Response response = await _dio.patch( MailApiPaths.message(id), data: changes.toJson(), + options: silent ? ApiOptions.silent() : null, ); final MessageSummary? summary = MessageSummary.tryParse(response.data); if (summary == null) throw _malformed(response); @@ -127,6 +170,130 @@ class MailRepository { return state; } + /// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다. + /// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송") + Future cancelPendingSend(String id) async { + final Response response = await _dio.delete( + MailApiPaths.scheduledMessage(id), + ); + final Object? data = response.data; + final Object? draftId = data is Map + ? data['draftId'] + : null; + if (draftId is! String || draftId.isEmpty) throw _malformed(response); + return draftId; + } + + /// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다). + /// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 — + /// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게) + Future fetchMailImage( + String token, { + required int maxBytes, + CancelToken? cancelToken, + }) async { + final CancelToken cancel = CancelToken(); + if (cancelToken != null) { + if (cancelToken.isCancelled) cancel.cancel(); + unawaited( + cancelToken.whenCancel.then((DioException _) { + if (!cancel.isCancelled) cancel.cancel(); + }), + ); + } + final Response> response = await _dio.get>( + MailApiPaths.mailImage(token), + options: Options( + responseType: ResponseType.bytes, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancel, + onReceiveProgress: (int received, int total) { + if (received > maxBytes || total > maxBytes) cancel.cancel(); + }, + ); + final List? data = response.data; + if (data == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + return data is Uint8List ? data : Uint8List.fromList(data); + } + + /// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null). + /// + /// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다), + /// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]). + /// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다 + Future downloadAttachment({ + required String messageId, + required String attachmentId, + required DownloadTarget target, + void Function(int received, int? total)? onProgress, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.attachment(messageId, attachmentId), + options: Options(responseType: ResponseType.stream), + cancelToken: cancelToken, + ); + final ResponseBody? body = response.data; + if (body == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + final int? length = int.tryParse( + response.headers.value(Headers.contentLengthHeader) ?? '', + ); + final int? total = length != null && length > 0 ? length : null; + final RequestOptions request = response.requestOptions; + final Completer done = Completer(); + int received = 0; + final StreamSubscription subscription = body.stream.listen( + (Uint8List chunk) { + target.add(chunk); + received += chunk.length; + onProgress?.call(received, total); + }, + onError: (Object error, StackTrace stackTrace) { + if (done.isCompleted) return; + // 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다 + final bool cancelled = + error is DioException && error.type == DioExceptionType.cancel; + done.completeError( + cancelled ? error : DownloadInterrupted(request), + stackTrace, + ); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + // 그만두면 다음 조각을 기다리지 않고 바로 끊는다 + unawaited( + cancelToken?.whenCancel.then((DioException _) async { + if (done.isCompleted) return; + done.completeError( + DioException.requestCancelled( + requestOptions: request, + reason: cancelToken.cancelError?.error, + ), + ); + await subscription.cancel(); + }), + ); + await done.future; + } + DioException _malformed(Response response) => apiFailure(response.requestOptions, kFallbackCode); } + +/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다) +class DownloadInterrupted extends DioException { + DownloadInterrupted(RequestOptions requestOptions) + : super( + requestOptions: requestOptions, + type: DioExceptionType.connectionError, + error: ApiException.fromCode(kFallbackCode), + ); +} diff --git a/flutter/lib/features/mail/domain/compose_request.dart b/flutter/lib/features/mail/domain/compose_request.dart new file mode 100644 index 0000000..20987dd --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_request.dart @@ -0,0 +1,200 @@ +/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수). +/// +/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 — +/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=` · `&all=1` · `?forward=` · `?draft=`)이고, +/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다 +/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게). +library; + +import 'package:flutter/foundation.dart'; + +/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다 +abstract final class ComposeQuery { + static const String draft = 'draft'; + static const String replyTo = 'replyTo'; + + /// [replyTo] 와 함께 `1` 이면 전체 답장 + static const String replyAll = 'all'; + static const String replyAllValue = '1'; + static const String forward = 'forward'; +} + +/// 쓰기 화면을 여는 까닭 +sealed class ComposeIntent { + const ComposeIntent(); +} + +/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill]) +final class NewComposeIntent extends ComposeIntent { + const NewComposeIntent(); +} + +/// 임시 저장본 이어 쓰기 +final class DraftComposeIntent extends ComposeIntent { + const DraftComposeIntent(this.draftId); + + final String draftId; +} + +/// 답장 · 전체 답장 +final class ReplyComposeIntent extends ComposeIntent { + const ReplyComposeIntent(this.messageId, {required this.all}); + + final String messageId; + final bool all; +} + +/// 전달 +final class ForwardComposeIntent extends ComposeIntent { + const ForwardComposeIntent(this.messageId); + + final String messageId; +} + +/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달) +ComposeIntent composeIntentOf(Map query) { + final String? draft = query[ComposeQuery.draft]; + if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft); + final String? replyTo = query[ComposeQuery.replyTo]; + if (replyTo != null && replyTo.isNotEmpty) { + return ReplyComposeIntent( + replyTo, + all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue, + ); + } + final String? forward = query[ComposeQuery.forward]; + if (forward != null && forward.isNotEmpty) { + return ForwardComposeIntent(forward); + } + return const NewComposeIntent(); +} + +/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다 +@immutable +class ComposePrefill { + const ComposePrefill({ + this.to = const [], + this.cc = const [], + this.bcc = const [], + this.subject = '', + this.body = '', + }); + + final List to; + final List cc; + final List bcc; + final String subject; + final String body; + + @override + bool operator ==(Object other) => + other is ComposePrefill && + listEquals(other.to, to) && + listEquals(other.cc, cc) && + listEquals(other.bcc, bcc) && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash( + Object.hashAll(to), + Object.hashAll(cc), + Object.hashAll(bcc), + subject, + body, + ); +} + +/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명) +const int kComposeMaxRecipients = 100; + +/// 제목 한도 — 발송 한도와 같다(255자) +const int kComposeMaxSubject = 255; + +/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다) +const int kComposeMaxPrefillBody = 10000; + +/// 주소 한 칸의 최대 길이 (RFC 5321) +const int _maxAddressLength = 254; + +/// mailto 링크 → 채울 값 — mailto 가 아니면 null. +/// +/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만, +/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면). +ComposePrefill? composePrefillOfMailto(Uri uri) { + if (uri.scheme.toLowerCase() != 'mailto') return null; + final Map query = {}; + final String rawQuery = uri.hasQuery ? uri.query : ''; + if (rawQuery.isNotEmpty) { + for (final String part in rawQuery.split('&')) { + final int equals = part.indexOf('='); + final String key = _decode( + equals == -1 ? part : part.substring(0, equals), + ).toLowerCase(); + final String value = equals == -1 + ? '' + : _decode(part.substring(equals + 1)); + // 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다 + if (key == 'to' || key == 'cc' || key == 'bcc') { + query[key] = query[key] == null ? value : '${query[key]},$value'; + } else { + query.putIfAbsent(key, () => value); + } + } + } + final Set seen = {}; + List addresses(String raw) { + final List out = []; + for (final String piece in raw.split(',')) { + final String address = piece.trim(); + if (!_isPlausibleAddress(address)) continue; + if (seen.length >= kComposeMaxRecipients) break; + if (!seen.add(address.toLowerCase())) continue; + out.add(address); + } + return List.unmodifiable(out); + } + + return ComposePrefill( + to: addresses([_decode(uri.path), query['to'] ?? ''].join(',')), + cc: addresses(query['cc'] ?? ''), + bcc: addresses(query['bcc'] ?? ''), + subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject), + body: _limit(query['body'] ?? '', kComposeMaxPrefillBody), + ); +} + +/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다 +String _decode(String value) { + try { + return Uri.decodeComponent(value); + } on ArgumentError { + return value; + } on FormatException { + return value; + } +} + +bool _isPlausibleAddress(String address) { + if (address.isEmpty || address.length > _maxAddressLength) return false; + final int at = address.indexOf('@'); + if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) { + return false; + } + return !address.runes.any( + (int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F), + ); +} + +/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로 +String _singleLine(String value) => + value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim(); + +/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다 +String _limit(String value, int max) { + if (value.length <= max) return value; + final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max; + return value.substring(0, end); +} + +bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF; diff --git a/flutter/lib/features/mail/domain/mail_body_notices.dart b/flutter/lib/features/mail/domain/mail_body_notices.dart new file mode 100644 index 0000000..6b9cd32 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_body_notices.dart @@ -0,0 +1,32 @@ +/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다). +/// +/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다. +/// 글자 본문은 공백뿐이면 없는 것으로 본다. +library; + +/// 본문 안내의 종류 +enum MailBodyNotice { + /// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다 + htmlOmitted, + + /// 서식 본문을 비웠고 글자 본문도 없다 + unavailable, + + /// 보여 주는 본문이 읽기 한도에서 잘렸다 + truncated, +} + +/// 본문 위에 둘 안내 — 없으면 빈 목록 +List mailBodyNoticesOf({ + required String? textBody, + required bool isHtmlBodyOmitted, + required bool isBodyTruncated, +}) { + final bool hasText = (textBody ?? '').trim().isNotEmpty; + return [ + if (isHtmlBodyOmitted) + hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable, + if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText)) + MailBodyNotice.truncated, + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart new file mode 100644 index 0000000..0812d29 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -0,0 +1,503 @@ +/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다). +/// +/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다. +/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙") +/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다. +/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라 +/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다). +/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만), +/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가 +/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다. +/// +/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 — +/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다. +library; + +import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다. +/// 웹(`MAIL_FRAME_CSP`)과 같고, `` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다). +/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다) +const String kMailDocumentCsp = + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + +/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다 +/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`) +final RegExp kProxiedImagePattern = RegExp( + r'^/api/mail-images/([A-Za-z0-9_-]+)$', +); + +/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개) +const int kMaxProxiedImages = 100; + +/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다 +final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false); + +/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다 +final RegExp _dataImage = RegExp( + r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$', + caseSensitive: false, +); + +/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다) +const Set _allowedTags = { + 'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', // + 'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', // + 'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', // + 'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', // + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', // + 'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', // + 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', // + 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', // + 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', +}; + +/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록) +const Set _droppedWithContent = { + 'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', // + 'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', // + 'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', // + 'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', // + 'base', 'input', 'select', 'dialog', 'slot', +}; + +/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다 +const Set _voidTags = {'br', 'col', 'hr', 'img', 'wbr'}; + +/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다 +const Set _globalAttributes = { + 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', // + 'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', // + 'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', // + 'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', // + 'noshade', 'datetime', +}; + +/// 태그마다 더 남기는 속성 +const Map> _tagAttributes = >{ + 'a': {'href'}, + 'img': {'src', 'alt'}, + 'ol': {'start', 'type', 'reversed'}, + 'ul': {'type'}, + 'li': {'type', 'value'}, + 'details': {'open'}, +}; + +/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로) +const int _maxAttributeLength = 1000; +const int _maxStyleLength = 4000; + +/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다 +const int _maxHrefLength = 2048; + +/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000) +const int kMaxSanitizedHtmlLength = 2000000; + +/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]). +/// +/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다 +/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다). +/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다 +/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다) +const int kMaxHtmlNestingCost = 8000000; + +/// 정화한 본문 조각 — `` 안에 넣는다 +class SanitizedMailBody { + const SanitizedMailBody({ + required this.fragment, + required this.proxiedTokens, + this.htmlOmitted = false, + }); + + /// 정화한 HTML 조각 — 중계 그림은 ` proxiedTokens; + + /// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다 + final bool htmlOmitted; +} + +/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙) +String? displayedHtmlBody(String? htmlBody) => + htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null; + +/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다. +/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). +/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다 +SanitizedMailBody mailBodyFragment({ + required String? htmlBody, + required String? textBody, + required bool imagesShown, +}) { + final String? html = displayedHtmlBody(htmlBody); + if (html == null) return _textFragment(textBody); + final String trimmed = html.trim(); + if (trimmed.length > kMaxSanitizedHtmlLength || + htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) > + kMaxHtmlNestingCost) { + return _textFragment(textBody, htmlOmitted: true); + } + return sanitizeMailHtml(trimmed, imagesShown: imagesShown); +} + +SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) => + SanitizedMailBody( + fragment: + '
${escapeHtmlText(textBody ?? '')}
', + proxiedTokens: const [], + htmlOmitted: htmlOmitted, + ); + +/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다. +/// +/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다): +/// - 암묵 닫힘(`

` 뒤의 `

` 등)을 보지 않고, 원시 글(`'; + + test('직렬화 · 글 · 태그로 찾기가 package:html 과 같다', () { + final dom.Element body = html_parser.parse(sample).body!; + expect(innerHtmlOf(body), body.innerHtml); + final dom.Element div = body.children.single; + expect(outerHtmlOf(div), div.outerHtml); + expect(nodeTextOf(div), div.text); + expect(descendantElements(body, 'p'), body.querySelectorAll('p')); + }); + + test('2만 단으로 겹친 요소도 스택이 넘치지 않는다', () { + const int depth = 20000; + // 파서는 깊이의 제곱으로 느리므로 요소를 손으로 잇는다 + final dom.Element body = dom.Element.tag('body'); + dom.Element parent = body; + for (int level = 0; level < depth; level++) { + final dom.Element child = dom.Element.tag('div'); + parent.append(child); + parent = child; + } + parent.append(dom.Text('깊은 글')); + final String html = innerHtmlOf(body); + expect(html, startsWith('

')); + expect(html.length, depth * '
'.length + '깊은 글'.length); + expect(nodeTextOf(body), '깊은 글'); + expect(descendantElements(body, 'div'), hasLength(depth)); + }); + }); + + group('저장본 읽기 — 읽기 화면과 같은 한도 · 문턱(M1)', () { + test('읽을 곳 — 짧고 얕으면 그 자리, 길거나 깊으면 isolate, 한도를 넘으면 읽지 않는다', () { + expect( + draftBodyPlaceOf(bodyHtml: '

짧은 글

', body: ''), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf(bodyHtml: null, body: '글'), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf( + bodyHtml: '

${'가' * kMailBodyInlineMaxLength}

', + body: '', + ), + DraftBodyPlace.isolate, + ); + // 짧아도 깊으면(중첩 비용이 그 자리 문턱을 넘으면) isolate + expect( + draftBodyPlaceOf(bodyHtml: _nestedDivs(120, 'x'), body: ''), + DraftBodyPlace.isolate, + ); + expect( + draftBodyPlaceOf(bodyHtml: _nestedDivs(20000, 'x'), body: ''), + DraftBodyPlace.oversized, + ); + expect( + draftBodyPlaceOf( + bodyHtml: '

${'가' * kMaxSanitizedHtmlLength}

', + body: '', + ), + DraftBodyPlace.oversized, + ); + }); + + test('2만 단 저장본 — 읽지 않고 남의 저장본으로: 본문 칸은 텍스트 본문, 원래 HTML 은 그대로 쥔다', () async { + final String html = _nestedDivs(20000, '깊은 글'); + final DraftBody parsed = await buildDraftBody( + bodyHtml: ' $html ', + body: '깊은 글\n', + ); + expect(parsed, isA()); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.text, '깊은 글'); + expect(foreign.originalHtml, html); + expect(foreign.originalText, '깊은 글\n'); + expect(foreign.hasFormatting, isTrue); + expect(foreign.markedHtml, isNull); + expect(foreign.quote, isNull); + }); + + test('깊은 원문 인용이 든 저장본 — 다른 isolate 에서 읽고 원래 인용 HTML 을 쥔다', () async { + final String html = + '

앞 글

${_mailQuote(_nestedDivs(1500, '깊은 원문'))}
'; + expect( + draftBodyPlaceOf(bodyHtml: html, body: ''), + DraftBodyPlace.isolate, + ); + final DraftBody parsed = await buildDraftBody(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '앞 글'); + expect(app.quote!.header.single.value, '이도윤'); + expect(plainLinesOf(app.quote!.blocks), ['깊은 원문']); + expect(app.quote!.rawHtml, startsWith('

원본 메일

')); + }); + + test('바로 읽어도(한도 검사를 거치지 않아도) 깊은 인용 · 서명에서 스택이 넘치지 않는다', () { + final String html = + '

글

${_nestedDivs(2000, '서명')}
' + '${_mailQuote(_nestedDivs(2000, '원문'))}
'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.signature?.parts, const [ + SignatureTextPart('서명'), + ]); + expect( + app.quote!.rawHtml!.length, + greaterThan(2000 * '
'.length), + ); + }); + }); + + group('처음 맞추기 · 줄 자르기(L2)', () { + test('처음 인용은 덩이 만들기와 함께 내 글 몫(5%)을 남기고 맞춘다', () { + final ComposeQuote quote = initialQuoteOf( + ComposeQuoteRequest( + htmlBody: null, + textBody: List.filled(3000, '가' * 50).join('\n'), + isBodyTruncated: false, + header: _header, + summary: '이도윤', + signatureText: '김아라', + signatureHtml: '

김아라

', + ), + ); + expect(quote.omitted, isTrue); + expect(quote.blocks.length, lessThan(3000)); + final MailBodyOutput output = buildMailBody( + ComposeBodyInput( + text: '', + signatureHtml: '

김아라

', + signatureText: '김아라', + quote: quote, + ), + ); + expect(BodyLimits.initial.fits(output), isTrue); + + final ComposeQuote small = initialQuoteOf( + const ComposeQuoteRequest( + htmlBody: '

짧은 원문

', + textBody: '짧은 원문', + isBodyTruncated: true, + header: _header, + summary: '이도윤', + ), + ); + expect(plainLinesOf(small.blocks), ['짧은 원문']); + expect(small.omitted, isTrue); + }); + + for (final (String name, String line) in <(String, String)>[ + ('이스케이프되는 글자(& 5칸)', '&' * 200000), + ('탭(8칸까지 공백)', '${'\t' * 70000}끝'), + ('섞인 글', List.filled(40000, 'a&\t<😀 ').join()), + ]) { + test('긴 한 줄은 들어가는 만큼만 — 한 글자만 더 담아도 넘친다($name)', () { + final ComposeQuote quote = ComposeQuote( + header: _header, + blocks: [QuoteLine(line)], + summary: '', + ); + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: quote), + BodyLimits.full, + ); + expect(BodyLimits.full.fits(fit.output!), isTrue); + final String kept = (fit.quote!.blocks.single as QuoteLine).text; + expect(kept.length, lessThan(line.length)); + final int last = kept.codeUnitAt(kept.length - 1); + expect(last >= 0xD800 && last <= 0xDBFF, isFalse); + // 다음 글자(짝 글자면 둘)까지 담으면 넘친다 + final int next = + kept.length + + (line.codeUnitAt(kept.length) >= 0xD800 && + line.codeUnitAt(kept.length) <= 0xDBFF + ? 2 + : 1); + final MailBodyOutput longer = buildMailBody( + ComposeBodyInput( + text: '', + quote: quote.withBlocks([ + QuoteLine(line.substring(0, next)), + ], omitted: true), + ), + ); + expect(BodyLimits.full.fits(longer), isFalse); + }); + } + }); + + group('제목 길이 — UTF-16 단위(L3 · 서버 · 웹과 같다)', () { + test('truncateUtf16 — 짝 글자를 가르지 않는다', () { + expect(truncateUtf16('a' * 300, 255), 'a' * 255); + expect(truncateUtf16('${'a' * 254}😀', 255), 'a' * 254); + expect(truncateUtf16('짧은 글', 255), '짧은 글'); + }); + + TextEditingValue typed( + String text, { + TextRange composing = TextRange.empty, + }) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: composing, + ); + + test('포매터 — 붙여 넣은 글은 뒤를 자르고, 이모지는 두 칸으로 센다', () { + const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(10); + expect( + formatter.formatEditUpdate(typed(''), typed('a' * 30)).text, + 'a' * 10, + ); + expect( + formatter.formatEditUpdate(typed(''), typed('😀' * 8)).text, + '😀' * 5, + ); + expect( + formatter.formatEditUpdate(typed('a' * 9), typed('${'a' * 9}😀')).text, + 'a' * 9, + ); + }); + + test('포매터 — 한도만큼 찼으면 더 치는 글을 받지 않고, 한글을 조합하는 동안은 막지 않는다', () { + const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(5); + final TextEditingValue full = typed('abcde'); + expect(formatter.formatEditUpdate(full, typed('abXcde')), full); + final TextEditingValue composing = typed( + 'abcde한', + composing: const TextRange(start: 5, end: 6), + ); + expect(formatter.formatEditUpdate(full, composing), composing); + // 조합이 끝나면 자른다 + expect( + formatter.formatEditUpdate(composing, typed('abcde한')).text, + 'abcde', + ); + }); + }); +} diff --git a/flutter/test/features/mail/compose_repository_test.dart b/flutter/test/features/mail/compose_repository_test.dart new file mode 100644 index 0000000..c269a0e --- /dev/null +++ b/flutter/test/features/mail/compose_repository_test.dart @@ -0,0 +1,390 @@ +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +const MailComposePayload _payload = MailComposePayload( + to: [PersonAddress(email: 'doyun@x.kr', name: '이도윤')], + cc: [], + bcc: [], + subject: '계약', + body: '본문', + bodyHtml: '
본문
', + draftId: 'd1', +); + +Map _savedJson(String id) => { + 'id': id, + 'attachments': [attachmentJson('att-$id')], + 'updatedAt': '2026-11-07T00:00:00.000Z', +}; + +/// 쓰기 화면의 저장소 — 계약 2 · 3 · 4절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다) +void main() { + late ApiHarness h; + late ComposeRepository repository; + + setUp(() async { + h = ApiHarness(); + await h.prepare(); + await h.signIn(); + repository = ComposeRepository(h.dio); + }); + + test( + '저장 — 처음은 POST /drafts, 그다음은 PUT /drafts/{id}. 받기 한도 60초 · 알리지 않는다', + () async { + h.server.on( + 'POST', + ComposeApiPaths.drafts, + (_) => FakeResponse.ok(_savedJson('d2')), + ); + h.server.on( + 'PUT', + '/drafts/d%2F1', + (_) => FakeResponse.ok(_savedJson('d3')), + ); + + final DraftSaved created = await repository.createDraft( + _payload.withoutDraftId(), + ); + expect(created.id, 'd2'); + expect(created.attachments.single.id, 'att-d2'); + final RequestOptions post = h.server.requests.single; + expect(post.data, _payload.withoutDraftId().toJson()); + expect(post.receiveTimeout, kMailSendTimeout); + expect(bearerTokenOf(post), 'access-1'); + + final DraftSaved updated = await repository.updateDraft( + 'd/1', + _payload.withoutDraftId(), + ); + expect(updated.id, 'd3'); + + h.server.on( + 'PUT', + '/drafts/gone', + (_) => FakeResponse.fail(409, 'MAIL_008'), + ); + expect( + apiErrorOf( + await errorOf(repository.updateDraft('gone', _payload)), + ).code, + 'MAIL_008', + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('저장본 받기 — 모양이 틀리면 SYS_001, 실패는 알리지 않는다(화면이 [다시 시도]를 그린다)', () async { + h.server.on( + 'GET', + '/drafts/d1', + (_) => FakeResponse.ok({'id': 'd1'}), + ); + expect( + apiErrorOf(await errorOf(repository.getDraft('d1'))).code, + 'SYS_001', + ); + h.server.on('GET', '/drafts/d2', (_) => FakeResponse.fail(503, 'MAIL_001')); + expect( + apiErrorOf(await errorOf(repository.getDraft('d2'))).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test( + '보내기 — Idempotency-Key · 받기 한도 60초, 칸 안에 보일 코드만 알리지 않는다, 서버 시각을 준다', + () async { + final DateTime serverNow = DateTime.utc(2026, 11, 7, 9); + h.server.serverTime = serverNow; + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse(201, { + 'success': true, + 'data': { + 'id': 'sent-1', + 'rejectedRecipients': [ + {'email': 'a@x.kr', 'reason': 'not-found'}, + ], + 'pendingSend': pendingJson( + serverNow.add(const Duration(seconds: 10)), + ), + }, + }), + ); + + final ({SentMessage sent, DateTime? serverTime}) result = await repository + .sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ); + + expect(result.sent.id, 'sent-1'); + expect( + result.sent.rejectedRecipients.single.reason, + RejectedReason.notFound, + ); + expect( + result.sent.pendingSend?.sendAt, + serverNow.add(const Duration(seconds: 10)), + ); + expect(result.serverTime, serverNow); + final RequestOptions sent = h.server.requests.single; + expect(sent.headers[kIdempotencyHeader], 'key-1'); + expect(sent.receiveTimeout, kMailSendTimeout); + expect(sent.data, _payload.toJson()); + + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse.fail(400, 'MAIL_002'), + ); + expect( + apiErrorOf( + await errorOf( + repository.sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ), + ), + ).code, + 'MAIL_002', + ); + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf( + repository.sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 키로 다시 보낸다(서버가 두 번 내보내지 않게)', () async { + int next = 2; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + MailApiPaths.messages, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? const FakeResponse(201, { + 'success': true, + 'data': {'id': 'sent-1'}, + }) + : FakeResponse.fail(401, 'AUTH_001'), + ); + + final ({SentMessage sent, DateTime? serverTime}) result = await repository + .sendMessage(_payload, idempotencyKey: 'key-9'); + + expect(result.sent.id, 'sent-1'); + expect(result.sent.pendingSend, isNull); + final List sends = h.server.received( + 'POST', + MailApiPaths.messages, + ); + expect(sends, hasLength(2)); + expect( + sends.map((RequestOptions r) => r.headers[kIdempotencyHeader]), + ['key-9', 'key-9'], + ); + }); + + test('서명 · 서명 그림 · 보내기 설정 — 알리지 않는다, 그림은 한도를 넘으면 끊는다', () async { + h.server.on( + 'GET', + ComposeApiPaths.mailSignature, + (_) => FakeResponse.ok({ + 'bodyHtml': '

김아라

', + 'body': '김아라', + 'isForNew': true, + 'isForReply': false, + 'updatedAt': '2026-11-01T00:00:00.000Z', + }), + ); + final MailSignature signature = await repository.getSignature(); + expect(signature.bodyHtml, '

김아라

'); + expect(signature.isForReply, isFalse); + + h.server.on( + 'GET', + ComposeApiPaths.signatureImage('img-1'), + (_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'), + ); + final Uint8List bytes = await repository.fetchSignatureImage( + 'img-1', + maxBytes: 1024, + ); + expect(bytes, kTinyPng); + expect(bearerTokenOf(h.server.requests.last), 'access-1'); + await errorOf(repository.fetchSignatureImage('img-1', maxBytes: 4)); + + h.server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({'undoSendSeconds': 10}), + ); + expect(await repository.undoSendSeconds(), 10); + h.server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({}), + ); + expect( + apiErrorOf(await errorOf(repository.undoSendSeconds())).code, + 'SYS_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test( + '받는 사람 추천 · 그룹 · 구성원(다음 쪽까지) — 추천 · 그룹은 알리지 않는다, 고른 그룹의 실패는 알린다', + () async { + h.server.on( + 'GET', + ComposeApiPaths.recipientSuggestions, + (_) => FakeResponse.ok({ + 'items': [ + { + 'name': '이도윤', + 'email': 'doyun@x.kr', + 'source': 'directory', + }, + {'email': 'x'}, + {'name': 1, 'email': 'bad@x.kr'}, + {'email': 'new@x.kr', 'source': 'future'}, + ], + 'pageInfo': {'hasNext': true, 'nextCursor': 'c'}, + }), + ); + final RecipientSuggestionPage page = await repository + .listRecipientSuggestions(query: '도윤', limit: 16); + expect(page.items.map((RecipientSuggestion s) => s.email), [ + 'doyun@x.kr', + 'x', + 'new@x.kr', + ]); + expect(page.items.last.source, RecipientSource.recent); + expect(page.hasNext, isTrue); + expect(h.server.requests.last.queryParameters, { + 'q': '도윤', + 'limit': 16, + }); + + await repository.listRecipientSuggestions(); + expect(h.server.requests.last.queryParameters, { + 'limit': kRecipientSuggestionPreload, + }); + + h.server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'g1', 'name': '거래처', 'memberCount': 3}, + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + expect((await repository.listContactGroups()).single.name, '거래처'); + + h.server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) { + final bool first = options.queryParameters['cursor'] == null; + return FakeResponse.ok({ + 'items': [ + { + 'name': first ? '박지훈' : '최서연', + 'emails': [ + { + 'address': first ? 'jihoon@x.kr' : 'seoyeon@x.kr', + 'label': 'work', + }, + ], + }, + if (!first) + {'name': '주소 없음', 'emails': []}, + ], + 'pageInfo': { + 'hasNext': first, + 'nextCursor': first ? 'p2' : null, + }, + }); + }); + final List members = await repository.listGroupMembers('g1'); + expect(members, [ + (name: '박지훈', email: 'jihoon@x.kr'), + (name: '최서연', email: 'seoyeon@x.kr'), + (name: '주소 없음', email: null), + ]); + final List pages = h.server.received( + 'GET', + ComposeApiPaths.contacts, + ); + expect(pages.first.queryParameters, { + 'groupId': 'g1', + 'limit': kContactsPageSize, + }); + expect(pages.last.queryParameters['cursor'], 'p2'); + + h.server.on( + 'GET', + ComposeApiPaths.contacts, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf(repository.listGroupMembers('g1')); + expect(h.notifier.shownCodes, ['MAIL_001']); + }, + ); + + test('대용량 파일 상태 — 없으면 RES_001(알리지 않는다)', () async { + h.server.on( + 'GET', + ComposeApiPaths.fileUpload('u1'), + (_) => FakeResponse.ok({ + 'id': 'u1', + 'status': 'ready', + 'name': 'a.zip', + 'size': 5, + 'scanStatus': 'clean', + }), + ); + final DraftLargeFile file = await repository.getLargeFile('u1'); + expect(file.status, DraftLargeFileStatus.ready); + expect(file.infected, isFalse); + expect( + apiErrorOf(await errorOf(repository.getLargeFile('u2'))).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); +} diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart new file mode 100644 index 0000000..fd97654 --- /dev/null +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -0,0 +1,920 @@ +import 'dart:math' as math; + +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; +import 'package:arasystem_app/features/mail/domain/compose_reply.dart'; +import 'package:arasystem_app/features/mail/domain/compose_send.dart'; +import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +const String _nbsp = ' '; +const String _wrapper = + '
'; +const String _p = '

'; +const String _mailQuoteOpen = + '

'; +const String _nestOpen = + '
'; + +const List _header = [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '계약'), +]; + +const ComposeQuote _quote = ComposeQuote( + header: _header, + blocks: [ + QuoteLine('안녕하세요'), + QuoteLine(''), + QuoteNest([QuoteLine('이전 글')]), + ], + summary: '이도윤 · 11월 5일 (목) 오전 10:42', +); + +/// 시간대와 상관없이 같은 벽시계 — 지역 시각을 UTC 글로(읽으면 다시 그 지역 시각) +String _localIso(int year, int month, int day, int hour, int minute) => + DateTime(year, month, day, hour, minute).toUtc().toIso8601String(); + +MessageDetail _detail(Map overrides) => + MessageDetail.tryParse({ + ...messageJson('m1', mailboxId: kInboxId), + 'cc': [], + 'bcc': [], + 'replyTo': [], + 'htmlBody': null, + 'textBody': '본문', + 'hasBlockedImages': false, + 'attachments': [], + ...overrides, + })!; + +void main() { + group('보내는 본문 — 서식 본문 + 텍스트 (짝: 웹 mail-serialize.ts)', () { + test('내 글만 — 줄마다 문단, 빈 줄은
, 끝의 빈 줄은 보내지 않는다', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'a\n\nb\n\n'), + ); + expect(output.html, '$_wrapper${_p}a

$_p

${_p}b

'); + expect(output.text, 'a\n\nb'); + expect(output.quoteStart, isNull); + }); + + test('쓴 글이 없으면 텍스트 메일(서식 본문 없음)', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: '\n \n'), + ); + expect(output.html, isNull); + expect(output.text, ''); + }); + + test('글은 이스케이프한다 — 꺾쇠 · & 가 태그가 되지 않는다', () { + expect( + buildMailBody(const ComposeBodyInput(text: 'x < y & z')).html, + '$_wrapper${_p}x < y & <b>z</b>

', + ); + }); + + test('여러 칸 띄어쓰기 · 줄 앞 공백 · 탭은 nbsp 와 번갈아(웹 preserveSpaces 와 같은 글)', () { + expect( + preserveSpaces(' 1. 일시 : 10월 27일'), + '$_nbsp 1. 일시 $_nbsp $_nbsp: 10월 27일', + ); + expect(preserveSpaces('\tx'), '$_nbsp $_nbsp $_nbsp $_nbsp x'); + expect(preserveSpaces('ab\ty'), 'ab $_nbsp $_nbsp ${_nbsp}y'); + expect(preserveSpaces('한 칸 띄어쓰기'), '한 칸 띄어쓰기'); + // 텍스트 본문의 공백은 그대로다 + expect( + buildMailBody(const ComposeBodyInput(text: ' 들여 쓴 줄')).text, + ' 들여 쓴 줄', + ); + }); + + test('서명 — 빈 문단 하나 뒤 속성 없는 경계 div, 텍스트는 빈 줄 뒤 텍스트 서명', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput( + text: 'hi', + signatureHtml: '$_p김아라

', + signatureText: '김아라\n경영지원팀', + ), + ); + expect( + output.html, + '$_wrapper${_p}hi

$_p

$_p김아라

', + ); + expect(output.text, 'hi\n\n김아라\n경영지원팀'); + }); + + test('원문 인용 — "원본 메일" 문단 + 머리 표 + 문단 · 안쪽 인용, 텍스트는 "> " 줄', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'ok', quote: _quote), + ); + const String label = + ''; + const String value = ''; + expect( + output.html, + '$_wrapper${_p}ok

' + '$_mailQuoteOpen' + '

원본 메일

' + '' + '$label보낸 사람$value이도윤 <doyun@x.kr>' + '$label날짜${value}2026. 11. 5. (목) 오전 10:42' + '$label제목$value계약' + '
' + '$_p안녕하세요

$_p

$_nestOpen$_p이전 글

' + '', + ); + expect( + output.text, + 'ok\n' + '> 원본 메일\n' + '> 보낸 사람: 이도윤 \n' + '> 날짜: 2026. 11. 5. (목) 오전 10:42\n' + '> 제목: 계약\n' + '>\n' + '> 안녕하세요\n' + '>\n' + '> > 이전 글', + ); + expect(output.quoteStart, 'ok\n'.length); + expect(output.text.substring(output.quoteStart!), startsWith('> 원본 메일')); + expect(quoteStartInText(output.text), output.quoteStart); + }); + + test('대용량 파일이 있으면 원문 인용 바로 앞에 인용 경계 표시', () { + final String html = buildMailBody( + const ComposeBodyInput( + text: 'ok', + signatureHtml: '$_p서명

', + quote: _quote, + quoteStartMark: true, + ), + ).html!; + expect(html, contains('$kQuoteStartMarker$_mailQuoteOpen')); + }); + + test('줄인 인용은 끝에 생략 표시 · 남의 저장본의 인용은 원래 HTML 그대로', () { + final MailBodyOutput omitted = buildMailBody( + ComposeBodyInput( + text: '', + quote: _quote.withBlocks(const [ + QuoteLine('앞부분'), + ], omitted: true), + ), + ); + expect( + omitted.html, + endsWith('$_p앞부분

$_p$kQuoteOmittedMark

'), + ); + expect(omitted.text, endsWith('> 앞부분\n> $kQuoteOmittedMark')); + + const String raw = '

원본 메일

'; + final MailBodyOutput kept = buildMailBody( + const ComposeBodyInput( + text: 'x', + quote: ComposeQuote( + header: _header, + blocks: [QuoteLine('글')], + summary: '', + rawHtml: raw, + ), + ), + ); + expect(kept.html, '$_wrapper${_p}x

$raw'); + }); + }); + + group('길이 맞추기 (짝: 웹 fit-draft.ts)', () { + ComposeQuote longQuote(int lines, {int width = 100}) => ComposeQuote( + header: _header, + blocks: [ + for (int index = 0; index < lines; index++) QuoteLine('가' * width), + ], + summary: '', + ); + + test('한도 안이면 그대로', () { + final BodyFit fit = fitMailBody( + const ComposeBodyInput(text: 'ok', quote: _quote), + BodyLimits.full, + ); + expect(fit.quote, _quote); + expect(fit.output?.text, startsWith('ok\n> 원본 메일')); + }); + + test('처음 초안은 내 글 몫(5%)을 남긴다 — 95,000 · 475,000', () { + expect(BodyLimits.initial.text, 95000); + expect(BodyLimits.initial.html, 475000); + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: longQuote(2000)), + BodyLimits.initial, + ); + expect(fit.output!.text.length, lessThanOrEqualTo(95000)); + expect(fit.output!.html!.length, lessThanOrEqualTo(475000)); + expect(fit.quote!.omitted, isTrue); + expect(fit.quote!.blocks.length, lessThan(2000)); + expect(fit.output!.text, endsWith('> $kQuoteOmittedMark')); + }); + + test('보낼 때는 한도 그대로 다시 잰다 — 넘으면 인용을 뒤에서 덜어 낸다', () { + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '내 글' * 10000, quote: longQuote(2000)), + BodyLimits.full, + ); + expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax)); + expect(fit.output!.text, startsWith('내 글' * 10000)); + expect(fit.quote!.omitted, isTrue); + }); + + test('내 글만으로 넘으면 보내지 않는다(MAIL_007 — 결과 없음)', () { + expect( + fitMailBody( + ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1), quote: _quote), + BodyLimits.full, + ).output, + isNull, + ); + expect( + fitMailBody( + ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1)), + BodyLimits.full, + ).output, + isNull, + ); + }); + + test('긴 한 줄짜리 원문은 들어가는 만큼 글자 단위로 — 짝 글자를 가르지 않는다', () { + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: longQuote(1, width: 200000)), + BodyLimits.full, + ); + final QuoteLine line = fit.quote!.blocks.single as QuoteLine; + expect(line.text.length, greaterThan(90000)); + expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax)); + + final BodyFit emoji = fitMailBody( + ComposeBodyInput( + text: '', + quote: ComposeQuote( + header: _header, + blocks: [QuoteLine('😀' * 60000)], + summary: '', + ), + ), + BodyLimits.full, + ); + final String cut = (emoji.quote!.blocks.single as QuoteLine).text; + final int last = cut.codeUnitAt(cut.length - 1); + expect(last >= 0xD800 && last <= 0xDBFF, isFalse); + }); + + test('대용량 첨부 덩이 몫 — 파일마다 이름 + 24, 머리 600', () { + expect(largeFileBlockTextReserve(const []), 0); + expect( + largeFileBlockTextReserve(const ['a.zip', '계약.pdf']), + 600 + 5 + 24 + 6 + 24, + ); + expect(BodyLimits.full.reserveText(1000).text, kMailBodyTextMax - 1000); + }); + }); + + group('원문 → 인용 덩이 (화면에 보이는 글과 같게)', () { + List lines(String html) => plainLinesOf(quoteBlocksFromHtml(html)); + + test('블록 ·
은 줄, 이어진 공백은 한 칸, 앞뒤 빈 줄은 뺀다', () { + expect(lines('

안녕 하세요

둘째
셋째

'), [ + '안녕 하세요', + '둘째', + '셋째', + ]); + }); + + test('목록은 "- " · "1. ", 칸이 여럿인 표 줄은 " | ", 링크는 "글 (주소)"', () { + expect( + lines( + '
  • 사과
  • 배
  1. 셋
' + '
이름값
' + '

문서 · 위험

', + ), + [ + '- 사과', + '- 배', + '3. 셋', + '이름 | 값', + '문서 (https://ara.example/x) · 위험', + ], + ); + }); + + test('원문 속 인용은 안쪽 인용, 스크립트 · 스타일 · 그림의 글은 뺀다', () { + final List blocks = quoteBlocksFromHtml( + '

위

안쪽

' + '그림', + ); + expect(blocks, const [ + QuoteLine('위'), + QuoteNest([QuoteLine('안쪽')]), + ]); + expect(quoteDisplayLines(blocks), [ + (text: '위', depth: 0), + (text: '안쪽', depth: 1), + ]); + }); + + test('읽기 화면과 같은 본문 고르기 — 그림뿐인 서식 본문이면 글 본문, 잘라 보낸 본문이면 생략 표시', () { + final QuoteSource onlyImage = quoteSourceOf( + htmlBody: '

', + textBody: '글 본문\n둘째 줄', + isBodyTruncated: false, + ); + expect(plainLinesOf(onlyImage.blocks), ['글 본문', '둘째 줄']); + + final QuoteSource truncated = quoteSourceOf( + htmlBody: '

서식 본문

', + textBody: '글 본문', + isBodyTruncated: true, + ); + expect(plainLinesOf(truncated.blocks), ['서식 본문']); + expect(truncated.truncated, isTrue); + }); + }); + + group('저장본 다시 열기 (계약 2절 "앱이 만드는 모양")', () { + const String signatureHtml = '$_p김아라

$_p경영지원팀

'; + + test('앱이 만든 모양 — 내 글 · 서명 · 인용으로 되살린다(nbsp 는 공백으로)', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput( + text: '첫 줄\n 들여 쓴 줄\n\n끝', + signatureHtml: signatureHtml, + signatureText: '김아라', + quote: _quote, + ), + ); + final DraftBody parsed = draftBodyOf( + bodyHtml: output.html, + body: output.text, + ); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '첫 줄\n 들여 쓴 줄\n\n끝'); + expect(app.signature?.html, signatureHtml); + expect(app.signature?.parts, const [ + SignatureTextPart('김아라'), + SignatureTextPart('경영지원팀'), + ]); + expect(app.quote!.header, _header); + expect(app.quote!.blocks, _quote.blocks); + expect(app.quote!.rawHtml, isNotNull); + expect(app.quote!.summary, '이도윤 · 2026. 11. 5. (목) 오전 10:42'); + }); + + test('서버 정화로 style 띄어쓰기 · 경계 표시가 바뀌어도 앱 모양으로 알아본다', () { + // 서버의 보내는 정화가 내는 꼴 — style 은 "속성:값" 을 ; 로 잇고 data-* 는 지운다 + final String html = + buildMailBody( + const ComposeBodyInput( + text: '글', + signatureHtml: signatureHtml, + quote: _quote, + quoteStartMark: true, + ), + ).html! + .replaceAll( + kMailBodyWrapperStyle, + "font-family:'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;font-size:14px;line-height:1.75", + ) + .replaceAll('margin: 0', 'margin:0') + .replaceAll(' data-ara-quote-start=""', ''); + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: ''); + expect(parsed, isA()); + expect((parsed as AppDraftBody).text, '글'); + expect(parsed.quote, isNotNull); + }); + + test('서식이 있는 남의 저장본 — 인용 앞은 글로, 안내 줄을 보인다(원래 HTML 은 쥔다)', () { + const String html = + '

안녕 굵게

서명

' + '

원본 메일

보낸 사람이도윤 <d@x.kr>

원문

'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '원래 텍스트'); + expect(parsed, isA()); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.text, '안녕 굵게\n서명'); + expect(foreign.hasFormatting, isTrue); + expect(foreign.originalHtml, html); + expect(foreign.originalText, '원래 텍스트'); + expect(foreign.quote!.header.single.value, '이도윤 '); + expect(foreign.quote!.summary, '이도윤'); + expect(foreign.quote!.rawHtml, isNotNull); + }); + + test('서식이 없는 남의 저장본은 안내하지 않는다 · 서식 본문이 없으면 텍스트 그대로', () { + final DraftBody plain = draftBodyOf( + bodyHtml: '
a

b

', + body: '', + ); + expect(plain, isA()); + expect((plain as ForeignDraftBody).hasFormatting, isFalse); + expect(plain.text, 'a\nb'); + final DraftBody text = draftBodyOf(bodyHtml: null, body: '글 본문\n\n'); + expect(text, isA()); + expect((text as AppDraftBody).text, '글 본문'); + }); + + test('남의 저장본은 원문 인용 앞에 인용 경계 표시를 넣은 HTML 을 읽을 때 한 번 만들어 둔다', () { + const String html = + '

a b

원본 메일

'; + final ForeignDraftBody foreign = + draftBodyOf(bodyHtml: html, body: 'a b') as ForeignDraftBody; + expect( + foreign.markedHtml, + '

a b

원본 메일

', + ); + expect(foreign.originalHtml, html); + final ForeignDraftBody noQuote = + draftBodyOf(bodyHtml: '

인용 없음

', body: '') + as ForeignDraftBody; + expect(noQuote.markedHtml, isNull); + }); + + // 웹 저장본 표본 — 문단의 정렬 · 들여쓰기 · 글자 꾸밈 · 다른 감싸개는 앱 모양이 아니다(말없이 지우지 않게 원래 HTML 을 쥐고 안내한다) + for (final (String name, String html) in <(String, String)>[ + ('가운데 정렬', '

가운데

'), + ('들여쓰기', '

들여 쓴 줄

'), + ('굵게', '

글 굵게

'), + ('문단의 다른 속성', '

글

'), + ( + '꾸민 감싸개', + '

글

', + ), + ( + '꾸민 서명 div', + '

글

서명

', + ), + ]) { + test('웹 저장본 — $name 은 남의 저장본(원래 HTML 을 쥐고 안내 줄)', () { + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '글'); + expect(parsed, isA(), reason: name); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.hasFormatting, isTrue, reason: name); + expect(foreign.originalHtml, html, reason: name); + }); + } + + test('꾸밈 없는 문단(속성 없음 · margin:0 하나)만 든 웹 저장본은 앱 모양으로 연다', () { + final DraftBody parsed = draftBodyOf( + bodyHtml: + '
' + '

첫 줄

둘째 줄

', + body: '', + ); + expect(parsed, isA()); + expect((parsed as AppDraftBody).text, '첫 줄\n둘째 줄'); + }); + }); + + group('서명 (계약 2절 "서명" — 짝: 웹 mail-signature.ts)', () { + test('새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만', () { + const MailSignature signature = MailSignature( + body: '김아라', + isForNew: false, + isForReply: true, + ); + expect(composeSignatureOf(signature, forReply: false), isNull); + expect(composeSignatureOf(signature, forReply: true), isNotNull); + expect(composeSignatureOf(null, forReply: true), isNull); + expect( + composeSignatureOf(const MailSignature(body: ' '), forReply: true), + isNull, + ); + }); + + test('텍스트 서명은 줄마다 문단', () { + final ComposeSignature signature = composeSignatureOf( + const MailSignature(body: '김아라\n경영지원팀'), + forReply: false, + )!; + expect(signature.html, '$_p김아라

$_p경영지원팀

'); + expect(signature.text, '김아라\n경영지원팀'); + expect(signature.parts, const [ + SignatureTextPart('김아라'), + SignatureTextPart('경영지원팀'), + ]); + }); + + test('서식 서명 — 문단에 margin: 0 을 앞에, 미리 보기는 글 줄과 서명 그림', () { + const String id = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + final ComposeSignature signature = composeSignatureOf( + const MailSignature( + bodyHtml: + '

김아라 경영지원팀

' + '

로고

' + '

', + body: '김아라 경영지원팀', + ), + forReply: false, + )!; + expect( + signature.html, + startsWith('

김아라'), + ); + expect(signature.html, contains('

[ + SignatureTextPart('김아라 경영지원팀'), + SignatureImagePart(id: id, width: 320, alt: '로고'), + ]); + }); + }); + + group('답장 · 전달 (짝: 웹 mail-reply.ts · mail-forward.ts · mail-draft.ts)', () { + test('제목 — 접두어가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다)', () { + expect(buildReplySubject('계약'), 'Re: 계약'); + expect(buildReplySubject(' RE : 계약'), 'RE : 계약'); + expect(buildReplySubject('회신: 계약'), '회신: 계약'); + expect(buildReplySubject(''), 'Re:'); + expect(buildForwardSubject('계약'), 'Fwd: 계약'); + expect(buildForwardSubject('FW: 계약'), 'FW: 계약'); + expect(buildForwardSubject('전달: 계약'), '전달: 계약'); + expect(fitDraftSubject('a' * 300), 'a' * 255); + expect(fitDraftSubject('${'a' * 254}😀'), 'a' * 254); + }); + + const String me = 'ara.kim@arasystem.it.kr'; + Map person(String name, String email) => { + 'name': name, + 'email': email, + }; + + List emails(List list) => [ + for (final PersonAddress address in list) address.email, + ]; + + test('답장은 보낸 사람(Reply-To 가 있으면 그 주소), 전체 답장은 받는 사람 · 참조까지 — 나는 뺀다', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [ + person('김아라', 'ARA.KIM@arasystem.it.kr'), + person('박지훈', 'jihoon@x.kr'), + ], + 'cc': [ + person('최서연', 'seoyeon@x.kr'), + person('', 'jihoon@x.kr'), + ], + }); + final ({List to, List cc}) reply = + buildReplyRecipients(message, me, all: false); + expect(emails(reply.to), ['doyun@x.kr']); + expect(reply.cc, isEmpty); + + final ({List to, List cc}) all = + buildReplyRecipients(message, me, all: true); + expect(emails(all.to), ['doyun@x.kr', 'jihoon@x.kr']); + expect(emails(all.cc), ['seoyeon@x.kr']); + + final MessageDetail withReplyTo = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('지원팀', 'help@x.kr')], + }); + expect( + emails(buildReplyRecipients(withReplyTo, me, all: false).to), + ['help@x.kr'], + ); + }); + + test('내가 보낸 메일에 답장하면 원래 받는 사람에게, 나에게만 보냈으면 나에게', () { + final MessageDetail own = _detail({ + 'from': person('김아라', me), + 'to': [person('이도윤', 'doyun@x.kr')], + 'replyTo': [person('다른 곳', 'else@x.kr')], + }); + expect(emails(buildReplyRecipients(own, me, all: false).to), [ + 'doyun@x.kr', + ]); + final MessageDetail toSelf = _detail({ + 'from': person('김아라', me), + 'to': [person('김아라', me)], + }); + expect(emails(buildReplyRecipients(toSelf, me, all: false).to), [ + me, + ]); + }); + + test('Reply-To 가 나뿐이면 원래 받는 사람에게', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('김아라', me)], + 'to': [person('김아라', me), person('박지훈', 'jihoon@x.kr')], + }); + expect(emails(buildReplyRecipients(message, me, all: false).to), [ + 'jihoon@x.kr', + ]); + }); + + test('인용 머리 — 날짜는 웹과 같은 글, 전달은 받는 사람 · 참조도', () { + expect( + formatQuoteDate(DateTime(2026, 11, 5, 10, 42)), + '2026. 11. 5. (목) 오전 10:42', + ); + expect( + formatQuoteDate(DateTime(2026, 11, 5, 15, 7)), + '2026. 11. 5. (목) 오후 3:07', + ); + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [person('김아라', me)], + 'cc': [person('', 'cc@x.kr')], + 'subject': '', + 'sentAt': _localIso(2026, 11, 5, 10, 42), + }); + expect(replyQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '(제목 없음)'), + ]); + expect(forwardQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('받는 사람', '김아라 '), + QuoteHeaderRow('참조', 'cc@x.kr'), + QuoteHeaderRow('제목', '(제목 없음)'), + ]); + // 접힌 줄의 둘째 줄 — 올해면 연도 없이, 아니면 연도까지 + expect( + quoteSummaryOf(message, now: DateTime(2026, 12, 1)), + '이도윤 · 11월 5일 (목) 오전 10:42', + ); + expect( + quoteSummaryOf(message, now: DateTime(2027, 1, 2)), + '이도윤 · 2026년 11월 5일 (목) 오전 10:42', + ); + }); + + test('이어 쓰는 저장본의 쓰기 종류 — 답장할 메일 · 전달 제목 · 새 메일', () { + expect(draftKindOf(inReplyTo: 'm1', subject: '계약'), ComposeKind.reply); + expect( + draftKindOf(inReplyTo: null, subject: 'Fwd: 계약'), + ComposeKind.forward, + ); + expect(draftKindOf(inReplyTo: '', subject: '계약'), ComposeKind.newMail); + }); + }); + + group( + '보내기 규칙 (짝: 웹 idempotency-key.ts · mail-send-result.ts · pending-send.ts · mail-attachment.ts)', + () { + test('발송 키 — UUID v4 꼴, 매번 다르다', () { + final RegExp uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + expect(createSendKey(), matches(uuid)); + expect(createSendKey(math.Random(1)), matches(uuid)); + expect(createSendKey(), isNot(createSendKey())); + }); + + test('키를 바꾸는 것은 성공과 MAIL_005 뿐 — 그 밖의 실패는 같은 키로 다시 보낸다', () { + expect(shouldRenewSendKey(kSendOutcomeSent), isTrue); + expect(shouldRenewSendKey('MAIL_005'), isTrue); + for (final String code in [ + 'MAIL_001', + 'MAIL_002', + 'SYS_001', + 'REQ_001', + 'MAIL_007', + ]) { + expect(shouldRenewSendKey(code), isFalse, reason: code); + } + }); + + test('받지 못한 사람 — 두 명까지 주소 · 까닭, 나머지는 "외 N명"', () { + expect(partialSendMessage(const []), isNull); + expect( + partialSendMessage(const [ + RejectedRecipient(email: 'a@x.kr', reason: RejectedReason.notFound), + RejectedRecipient(email: 'b@x.kr', reason: RejectedReason.unknown), + RejectedRecipient(email: 'c@x.kr', reason: RejectedReason.refused), + ]), + '보냈지만 3명은 받지 못했습니다. a@x.kr (없는 주소), b@x.kr 외 1명', + ); + expect( + partialSendMessage(const [ + RejectedRecipient( + email: 'a@x.kr', + reason: RejectedReason.temporary, + ), + ], pending: true), + '보냈지만 1명은 받지 못합니다. a@x.kr (지금은 받을 수 없음)', + ); + }); + + test( + '보내기 취소 마감 — 서버 시각으로 sendAt − 2초, 모르면 설정의 초 − 1초(모르면 30초)와 sendAt − 이 기기 시각 가운데 짧은 것(30초를 넘지 않는다)', + () { + final DateTime sendAt = DateTime.utc(2026, 11, 7, 9, 0, 10); + final DateTime device = DateTime.utc(2026, 11, 7, 9, 0, 0); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: DateTime.utc(2026, 11, 7, 9, 0, 0), + undoSendSeconds: 30, + deviceNow: device, + ), + const Duration(seconds: 8), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: 5, + deviceNow: device, + ), + const Duration(seconds: 4), + ); + // 설정을 모르면 sendAt 까지(이 기기 시각), 다만 30초를 넘지 않게 — 웹 undoToastDeadline 과 같다 + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: null, + deviceNow: device, + ), + const Duration(seconds: 10), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: null, + deviceNow: device.subtract(const Duration(hours: 1)), + ), + kUndoSendMax, + ); + // 설정을 알아도 sendAt 을 넘지 않는다(이 기기 시계가 빠르면 일찍 닫힌다 — 보낸 뒤에 취소를 보이지 않는 쪽) + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: 30, + deviceNow: device.add(const Duration(seconds: 7)), + ), + const Duration(seconds: 3), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: DateTime.utc(2026, 11, 7, 8, 0), + undoSendSeconds: null, + deviceNow: device, + ), + kUndoSendMax, + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: sendAt.subtract(const Duration(seconds: 1)), + undoSendSeconds: 10, + deviceNow: device, + ), + isNull, + ); + }, + ); + + test('이미 붙은 첨부의 사전 검사 — 개수 → 형식 → 합계(서버와 같은 차례)', () { + expect( + precheckCarriedAttachment( + name: '계약.PDF', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + isNull, + ); + expect( + precheckCarriedAttachment( + name: 'a.pdf', + size: 10, + attachedCount: 20, + attachedBytes: 0, + ), + 'MAIL_006', + ); + expect( + precheckCarriedAttachment( + name: 'a.exe', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + 'FILE_001', + ); + expect( + precheckCarriedAttachment( + name: 'noext', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + 'FILE_001', + ); + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 2, + attachedCount: 1, + attachedBytes: kMailAttachmentTotalMaxBytes - 1, + ), + 'FILE_002', + ); + }); + }, + ); + + group('발송 · 저장 본문 (계약 2절)', () { + test('빈 선택 칸은 싣지 않는다 · 저장본으로 남길 때는 draftId 를 뺀다', () { + const MailComposePayload payload = MailComposePayload( + to: [PersonAddress(email: 'a@x.kr', name: '가')], + cc: [], + bcc: [], + subject: '제목', + body: '본문', + draftId: 'd1', + ); + expect(payload.toJson(), { + 'to': [ + {'name': '가', 'email': 'a@x.kr'}, + ], + 'cc': [], + 'bcc': [], + 'subject': '제목', + 'body': '본문', + 'attachments': [], + 'draftId': 'd1', + }); + expect(payload.withoutDraftId().toJson().containsKey('draftId'), isFalse); + + const MailComposePayload full = MailComposePayload( + to: [], + cc: [], + bcc: [], + subject: '', + body: 'x', + bodyHtml: '

x
', + attachments: [(id: 'att1', name: '계약.pdf')], + inReplyTo: 'm1', + largeFileIds: ['u1'], + bodyQuoteStart: 3, + ); + expect(full.toJson(), containsPair('bodyHtml', '
x
')); + expect(full.toJson(), containsPair('inReplyTo', 'm1')); + expect(full.toJson(), containsPair('largeFileIds', ['u1'])); + expect(full.toJson(), containsPair('bodyQuoteStart', 3)); + expect(full.toJson()['attachments'], [ + {'id': 'att1', 'name': '계약.pdf'}, + ]); + }); + + test('저장본 응답 — 대용량 파일 상태 · 위험 판정을 읽는다(모양이 틀린 항목만 버린다)', () { + final Draft? draft = Draft.tryParse({ + 'id': 'd1', + 'to': [], + 'subject': '', + 'body': '', + 'updatedAt': '2026-11-05T01:00:00.000Z', + 'largeFiles': [ + { + 'id': 'u1', + 'status': 'ready', + 'name': 'a.zip', + 'size': 10, + 'scanStatus': 'infected', + }, + {'id': 'u2', 'status': 'missing'}, + {'id': 'u3', 'status': 'weird'}, + ], + }); + expect(draft, isNotNull); + expect(draft!.cc, isEmpty); + expect(draft.largeFiles, const [ + DraftLargeFile( + id: 'u1', + status: DraftLargeFileStatus.ready, + name: 'a.zip', + size: 10, + infected: true, + ), + DraftLargeFile(id: 'u2', status: DraftLargeFileStatus.missing), + ]); + expect(Draft.tryParse({'id': 'd1'}), isNull); + }); + }); +} diff --git a/flutter/test/features/mail/draft_autosave_test.dart b/flutter/test/features/mail/draft_autosave_test.dart new file mode 100644 index 0000000..357af43 --- /dev/null +++ b/flutter/test/features/mail/draft_autosave_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 저장 한 번의 결과 — 문([gate])을 두면 열릴 때까지 기다린다 +class _Server { + final List calls = []; + + /// 다음 저장들이 이 코드로 실패한다(차례대로 하나씩 쓴다) + final List failures = []; + Completer? gate; + int _next = 0; + + Future save(String call) async { + calls.add(call); + final Completer? wait = gate; + if (wait != null) await wait.future; + if (failures.isNotEmpty) { + throw apiFailure(RequestOptions(path: '/drafts'), failures.removeAt(0)); + } + _next++; + return DraftSaved( + id: 'd$_next', + attachments: const [], + updatedAt: DateTime.utc(2026, 11, 7), + ); + } +} + +/// 자동 저장 시험대 — 바뀜 여부는 [changed], 저장이 끝나면 저장한 판으로 삼는다(바뀜 없음) +class _Harness { + _Harness() { + autosave = DraftAutosave( + canSave: () => allowed, + hasChanges: () => changed, + prepare: () { + final int version = this.version; + return rejected == null + ? DraftSaveRequest( + payload: _payload, + commit: (DraftSaved _) { + // 저장하는 동안 더 바꿨으면 그대로 바뀜 + if (this.version == version) changed = false; + }, + ) + : DraftSaveRejected(rejected!); + }, + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + canFallback: () => fallback, + onSaved: (String? previousId, DraftSaved saved, bool replaced) => + saved_.add((previousId, saved.id, replaced)), + onFailed: (String code, DraftSaveTrigger trigger) => + failed.add((code, trigger)), + ); + } + + final _Server server = _Server(); + late final DraftAutosave autosave; + bool changed = false; + bool allowed = true; + bool fallback = true; + String? rejected; + int version = 0; + final List<(String?, String, bool)> saved_ = <(String?, String, bool)>[]; + final List<(String, DraftSaveTrigger)> failed = + <(String, DraftSaveTrigger)>[]; + + static const MailComposePayload _payload = MailComposePayload( + to: [], + cc: [], + bcc: [], + subject: '제목', + body: '본문', + ); + + /// 사용자가 무언가 바꿨다 + void edit() { + changed = true; + version++; + autosave.touch(); + } +} + +void main() { + // 시간은 Timer 로만 잰다 — 시험 시간(testWidgets 의 가짜 시계)을 넘긴다 + testWidgets('입력이 3초 멈추면 저장한다 — 처음은 POST, 그다음은 PUT {직전 id}', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 2)); + expect(h.server.calls, isEmpty); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, ['POST']); + expect(h.autosave.draftId, 'd1'); + expect(h.autosave.status, DraftSaveStatus.saved); + + await tester.pump(const Duration(seconds: 10)); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST', 'PUT d1']); + expect(h.saved_.last, ('d1', 'd2', false)); + h.autosave.dispose(); + }); + + testWidgets('저장은 10초에 한 번까지 — 그사이 바뀐 것은 문이 열릴 때 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + // 첫 저장을 시작한 지 3초 — 아직 문이 닫혀 있다(시작한 뒤 10초에 열린다) + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 6)); + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, ['POST', 'PUT d1']); + h.autosave.dispose(); + }); + + testWidgets('쉬지 않고 써도 처음 바꾼 뒤 30초가 지나면 저장한다', (WidgetTester tester) async { + final _Harness h = _Harness(); + for (int second = 0; second < 30; second++) { + h.edit(); + await tester.pump(const Duration(seconds: 1)); + } + expect(h.server.calls, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('한 번에 하나 — 저장하는 동안 바뀐 것은 끝난 뒤 다시', (WidgetTester tester) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.autosave.saving, isTrue); + h.edit(); + await tester.pump(const Duration(seconds: 15)); + expect(h.server.calls, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + // 끝났지만 그사이 바뀐 것이 남았다 — 문(10초)이 이미 열렸으므로 바로 + expect(h.server.calls, ['POST', 'PUT d1']); + await tester.pump(); + expect(h.changed, isFalse); + h.autosave.dispose(); + }); + + testWidgets('옛 저장본이 다른 곳에서 바뀌었거나 없으면 새로 남긴다(MAIL_008 · RES_001)', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.autosave.reset(existingId: 'old'); + h.server.failures.add('MAIL_008'); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['PUT old', 'POST']); + expect(h.autosave.status, DraftSaveStatus.replaced); + expect(h.saved_.single, ('old', 'd1', true)); + + // 세션이 바뀌었으면 새로 남기지 않는다 + final _Harness other = _Harness()..fallback = false; + other.autosave.reset(existingId: 'old'); + other.server.failures.add('RES_001'); + other.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(other.server.calls, ['PUT old']); + expect(other.failed.single, ('RES_001', DraftSaveTrigger.auto)); + h.autosave.dispose(); + other.autosave.dispose(); + }); + + testWidgets('실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본을 지우지 않는다)', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.autosave.reset(existingId: 'foreign', keepOriginal: true); + expect(h.autosave.draftId, isNull); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('다시 해 보면 나아질 실패는 15 · 30 · 60초 뒤 저절로, 그 밖은 바뀔 때까지 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.failures.addAll(['MAIL_001', 'SYS_001']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + expect(h.autosave.status, DraftSaveStatus.failed); + await tester.pump(const Duration(seconds: 14)); + expect(h.server.calls, hasLength(1)); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, hasLength(2)); + await tester.pump(const Duration(seconds: 29)); + expect(h.server.calls, hasLength(2)); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, hasLength(3)); + expect(h.autosave.status, DraftSaveStatus.saved); + + final _Harness quota = _Harness(); + quota.server.failures.add('MAIL_004'); + quota.edit(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(minutes: 5)); + expect(quota.server.calls, ['POST']); + expect(quota.failed.single.$1, 'MAIL_004'); + h.autosave.dispose(); + quota.autosave.dispose(); + }); + + testWidgets('보낼 수 없는 본문(MAIL_007)은 요청 없이 실패로 둔다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness()..rejected = 'MAIL_007'; + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, isEmpty); + expect(h.failed.single, ('MAIL_007', DraftSaveTrigger.auto)); + h.autosave.dispose(); + }); + + testWidgets('앱이 안 보이게 되면 10초 간격을 기다리지 않고 바로 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.edit(); + h.autosave.saveOnHide(); + await tester.pump(); + expect(h.server.calls, ['POST', 'PUT d1']); + // 바꾼 것이 없으면 저장하지 않는다 + h.autosave.saveOnHide(); + await tester.pump(); + expect(h.server.calls, hasLength(2)); + h.autosave.dispose(); + }); + + testWidgets('떠나기 전 — 진행 중인 저장을 기다린 뒤 남은 변경을 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.edit(); + bool? result; + unawaited( + h.autosave.saveBeforeLeave().then((bool saved) => result = saved), + ); + await tester.pump(); + expect(h.server.calls, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + await tester.pump(); + expect(h.server.calls, ['POST', 'PUT d1']); + expect(result, isTrue); + h.autosave.dispose(); + }); + + testWidgets('멈춘 동안(보내기 · 나가기 확인)은 저장하지 않고, 진행 중인 저장은 settle 로 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.autosave.suspend(); + h.edit(); + bool settled = false; + unawaited(h.autosave.settle().then((void _) => settled = true)); + await tester.pump(const Duration(seconds: 40)); + expect(settled, isFalse); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + expect(settled, isTrue); + await tester.pump(const Duration(seconds: 40)); + expect(h.server.calls, ['POST']); + + h.autosave.resume(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST', 'PUT d1']); + h.autosave.dispose(); + expect(h.autosave.hasPendingTimers, isFalse); + }); + + test('양식 없이 한 번 — PUT 이 막히면 같은 세션일 때만 POST 로 남긴다', () async { + final _Server server = _Server(); + server.failures.add('MAIL_008'); + const MailComposePayload payload = _Harness._payload; + final DraftSaved? saved = await saveDraftDetached( + payload: payload, + draftId: 'd0', + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + stillValid: () => true, + ); + expect(saved?.id, 'd1'); + expect(server.calls, ['PUT d0', 'POST']); + + server.failures.add('RES_001'); + expect( + await saveDraftDetached( + payload: payload, + draftId: 'd1', + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + stillValid: () => false, + ), + isNull, + ); + expect(server.calls, ['PUT d0', 'POST', 'PUT d1']); + }); +} diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index 7a7d5b2..5b2253f 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; @@ -18,7 +19,6 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_state import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; import 'package:arasystem_app/features/settings/settings_routes.dart'; -import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -77,14 +77,14 @@ void main() { MailListPage currentPage(WidgetTester tester) => tester.widget(find.byType(MailListPage)); - /// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 + /// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 화면)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 String pushedLocation(WidgetTester tester) { final Finder reader = find.byType(MessageReaderPage); return GoRouterState.of( tester.element( reader.evaluate().isNotEmpty ? reader.last - : find.byType(PlaceholderPage).last, + : find.byType(MessageComposePage).last, ), ).uri.toString(); } diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart new file mode 100644 index 0000000..915bb8d --- /dev/null +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -0,0 +1,1113 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/people/people_texts.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; +import 'package:arasystem_app/features/mail/domain/compose_request.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/compose_fakes.dart'; +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 쓰기 화면 — 앱 보드 `App1Compose` (가짜 서버 · 가짜 시계로 앱 전체를 띄운다) +void main() { + late MailAppHarness h; + late FakeComposeBackend c; + + // 원문 인용 · 저장본 본문은 시험에서 늘 이 isolate 에서 만든다(가짜 시계 안에서 다른 isolate 를 기다리지 않게) + Future inlineQuote(ComposeQuoteRequest request) async => + initialQuoteOf(request); + + /// 저장본 본문 읽기 — 시험이 바꿔 끼운다(실패 · 다시 시도) + late DraftBodyBuilder draftBuilder; + + setUp(() { + h = MailAppHarness(); + c = FakeComposeBackend(h.server, h.mail); + draftBuilder = ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + h.overrides.addAll([ + composeQuoteBuilderProvider.overrideWithValue(inlineQuote), + composeDraftBodyBuilderProvider.overrideWithValue( + ({required String? bodyHtml, required String body}) => + draftBuilder(bodyHtml: bodyHtml, body: body), + ), + ]); + h.server.serverTime = kTestNow; + }); + + final RegExp uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + + Future start(WidgetTester tester) async { + c.install(); + if (!h.mail.messages.containsKey(kInboxId)) h.mail.seed(kInboxId, 2); + await h.pump(tester); + } + + Future open( + WidgetTester tester, + String location, { + Object? extra, + }) async { + unawaited(h.router.push(location, extra: extra)); + await tester.pumpAndSettle(); + } + + Finder field(RecipientFieldKind kind) => find.descendant( + of: find.byKey(ValueKey(kind)), + matching: find.byType(TextField), + ); + final Finder subjectField = find.byKey( + const ValueKey('compose-subject'), + ); + final Finder bodyField = find.byKey(const ValueKey('compose-body')); + + /// 칩 · 칸 안 글 + Finder chip(String text) => find.descendant( + of: find.byType(ComposeRecipientField), + matching: find.text(text), + ); + String textOf(WidgetTester tester, Finder finder) => + tester.widget(finder).controller!.text; + bool focused(WidgetTester tester, Finder finder) => + tester.widget(finder).focusNode!.hasFocus; + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + Finder toast(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future tapTooltip(WidgetTester tester, String tooltip) async { + await tester.tap(find.byTooltip(tooltip)); + await tester.pumpAndSettle(); + } + + Future addRecipient(WidgetTester tester, String address) async { + await tester.enterText(field(RecipientFieldKind.to), '$address,'); + await tester.pump(); + } + + group('열기', () { + testWidgets( + '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹을 미리 받는다 · [파일 첨부]는 준비 중', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(c.suggestionRequests.single.queryParameters, { + 'limit': 100, + }); + expect(h.server.received('GET', '/contact-groups'), hasLength(1)); + // 참조 · 숨은참조는 접혀 있다 + expect( + find.byKey(const ValueKey(RecipientFieldKind.cc)), + findsNothing, + ); + + await tapTooltip(tester, MailComposeTexts.attach); + expect(snack(MailComposeTexts.attachNotReady), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.expandCopies); + expect(field(RecipientFieldKind.cc), findsOneWidget); + expect(field(RecipientFieldKind.bcc), findsOneWidget); + expect(find.byTooltip(MailComposeTexts.collapseCopies), findsOneWidget); + }, + ); + + testWidgets('mailto 로 연 새 메일 — 받는 사람 · 숨은참조(펼쳐 보인다) · 제목 · 본문을 채운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open( + tester, + MailRoutes.compose, + extra: const ComposePrefill( + to: ['kim@example.com'], + bcc: ['hidden@example.com'], + subject: 'hi', + body: '본문', + ), + ); + + expect(chip('kim@example.com'), findsOneWidget); + expect(chip('hidden@example.com'), findsOneWidget); + expect(textOf(tester, subjectField), 'hi'); + expect(textOf(tester, bodyField), '본문'); + // 받는 사람 · 제목이 있으니 본문에 초점 + expect(focused(tester, bodyField), isTrue); + }); + + testWidgets( + '답장 — 원본과 서명을 받아 채운다: 받는 사람 · "Re:" 제목 · 서명 · 원본 메일(접힘 — 누르면 펼친다)', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': false, + 'isForReply': true, + }; + h.mail.put( + kClientsId, + messageJson( + 'r1', + mailboxId: kClientsId, + subject: '유지보수 계약', + isRead: true, + ), + detail: {'textBody': '원문 첫 줄\n\n> 앞선 글'}, + ); + await start(tester); + await open(tester, MailRoutes.composeReply('r1')); + + expect(find.text(MailComposeTexts.reply), findsOneWidget); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, subjectField), 'Re: 유지보수 계약'); + expect(focused(tester, bodyField), isTrue); + expect(find.text('김아라'), findsOneWidget); + expect(find.text('경영지원팀'), findsOneWidget); + expect(find.textContaining('이도윤 · '), findsOneWidget); + expect(find.text('원문 첫 줄'), findsNothing); + + await tester.tap(find.text(MailComposeTexts.quote).first); + await tester.pumpAndSettle(); + expect(find.text('원문 첫 줄'), findsOneWidget); + expect(find.text('> 앞선 글'), findsOneWidget); + expect(find.text('보낸 사람'), findsOneWidget); + expect(find.text('이도윤 '), findsOneWidget); + expect(find.text('유지보수 계약'), findsOneWidget); + }, + ); + + testWidgets('전달 — 원본 첨부를 싣는다(형식이 맞지 않는 첨부는 까닭을 적고 싣지 않는다)', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f1', mailboxId: kInboxId, subject: '견적', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf'), + attachmentJson( + 'a2', + name: 'setup.exe', + type: 'application/octet-stream', + ), + attachmentJson( + 'a3', + name: 'logo.png', + type: 'image/png', + isInline: true, + size: 1024, + ), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f1')); + + expect(find.text(MailComposeTexts.forward), findsOneWidget); + expect(textOf(tester, subjectField), 'Fwd: 견적'); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('logo.png')); + expect(find.text('logo.png'), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + await addRecipient(tester, 'doyun@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + final Map body = c.sends.single.body; + expect(body['attachments'], [ + {'id': 'a1', 'name': '견적서.pdf'}, + ]); + expect(body.containsKey('inReplyTo'), isFalse); + expect(body['subject'], 'Fwd: 견적'); + }); + + testWidgets('원본을 받지 못하면 [다시 시도] · 없는 저장본이면 [닫기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd3', + to: >[addressJson('박준호', 'junho@example.com')], + ); + int calls = 0; + h.server.on('GET', '/drafts/d3', (_) { + calls++; + return calls == 1 + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(c.drafts['d3']); + }); + await start(tester); + await open(tester, MailRoutes.composeDraft('d3')); + + expect(find.text(MailComposeTexts.loadingTitle), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + // 저장본은 화면이 알리므로 전역 알림은 없다 + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + + h.router.pop(); + await tester.pumpAndSettle(); + await open(tester, MailRoutes.composeDraft('gone')); + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, MailComposeTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + }); + + group('리뷰 고침 (쓰기 화면 1차 리뷰)', () { + testWidgets('저장본 본문을 읽다 실패하면(네트워크 밖의 실패) 뼈대에 머물지 않고 [다시 시도]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd8', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '읽기 실패', + bodyHtml: '

글

', + body: '글', + ); + draftBuilder = + ({required String? bodyHtml, required String body}) async => + throw StateError('읽지 못함'); + await start(tester); + await open(tester, MailRoutes.composeDraft('d8')); + + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.retry), findsOneWidget); + + draftBuilder = + ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '읽기 실패'); + }); + + testWidgets('진행 중인 저장을 기다리는 사이 떠나도 그대로 보낸다 — 보내는 동안은 칸을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '느린 저장 뒤에 보낼 글'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'POST'); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + expect(tester.widget(bodyField).enabled, isFalse); + expect(tester.widget(subjectField).enabled, isFalse); + expect( + tester.widget(field(RecipientFieldKind.to)).enabled, + isFalse, + ); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + expect(c.sends.single.body['draftId'], 'saved-1'); + expect(c.sends.single.body['body'], '느린 저장 뒤에 보낼 글'); + expect(toast(MailComposeTexts.sent), findsOneWidget); + }); + + testWidgets('떠난 뒤 기다린 저장이 첨부를 찾지 못하면 보내지 않고 그 코드의 사전 문구로 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f2', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '견적서.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f2')); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '자료 보냅니다'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + // 찾지 못한 첨부가 있으면 저장본으로도 남기지 않는다(서버가 같은 까닭으로 거절한다) + expect(c.saves, hasLength(1)); + }); + + testWidgets('대용량 파일을 다시 묻는 사이 뺀 파일은 되살아나지 않는다(MAIL_016)', ( + WidgetTester tester, + ) async { + Map file(String id) => { + 'id': id, + 'status': 'ready', + 'name': '$id.zip', + 'size': 1048576, + }; + c.putDraft( + 'd9', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[file('u1'), file('u2')], + ); + c.largeFiles['u1'] = file('u1'); + c.largeFiles['u2'] = file('u2'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_016')); + final Completer gate = Completer(); + c.largeFileGate = gate; + await start(tester); + await open(tester, MailRoutes.composeDraft('d9')); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + // 다시 묻는 동안 u2 를 뺀다 + await tapTooltip(tester, MailComposeTexts.remove('u2.zip')); + expect(find.text('u2.zip'), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('u1.zip'), findsOneWidget); + expect(find.text('u2.zip'), findsNothing); + expect(snack(ErrorLexicon.getMessage('MAIL_016')), findsOneWidget); + }); + + testWidgets('나가기 확인 창이 떠 있는 동안 세션이 끝나면 창만 닫고 "저장했습니다"를 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '저장하지 못한 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(c.saves, hasLength(1)); + }); + + testWidgets('떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '떠나며 저장할 글'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(c.sends, isEmpty); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + expect(c.sends, isEmpty); + }); + }); + + group('받는 사람', () { + testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await addRecipient(tester, 'doyun@x.kr'); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + + await tester.enterText( + field(RecipientFieldKind.to), + '"Kim, Ara" \njihoon@x.kr\nbad@', + ); + await tester.pump(); + expect(chip('Kim, Ara'), findsOneWidget); + expect(chip('jihoon@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), 'bad@'); + + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + expect(chip('bad@'), findsOneWidget); + expect(tester.widget(chip('bad@')).style?.color, AppColors.danger); + + // 틀린 주소가 있으면 보내지 않고 칸 아래 띠 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('bad@')); + expect(chip('bad@'), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + }); + + testWidgets('받는 사람이 없으면 보내지 않고 칸 아래 띠 · 받는 사람 칸으로 초점', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.tap(subjectField); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + }); + + testWidgets( + '추천 — 묶음 이름 줄(그룹 · 사내 · 최근 주고받은 주소), 고르면 칩 · 그룹은 구성원을 넣고 칸 아래에 알린다', + (WidgetTester tester) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + { + 'name': '최도윤', + 'email': 'choi@partner.example', + 'source': 'recent', + }, + ]; + c.suggestionsHasNext = true; + c.groups = >[ + {'id': 'g1', 'name': '도윤 팀', 'memberCount': 2}, + ]; + c.members['g1'] = >[ + { + 'name': '박지훈', + 'emails': [ + {'address': 'jihoon@x.kr', 'label': 'work'}, + ], + }, + {'name': '주소 없음', 'emails': []}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('그룹'), findsOneWidget); + expect(find.text('사내'), findsOneWidget); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + expect(find.text(PeopleTexts.groupDetail(2)), findsOneWidget); + // 받아 둔 것이 모자라면 250ms 뒤 서버에도 묻는다 + expect(c.suggestionRequests.last.queryParameters['q'], '도윤'); + + await tester.tap(find.text('도윤 팀')); + await tester.pumpAndSettle(); + expect(chip('박지훈'), findsOneWidget); + expect( + findCopy('‘도윤 팀’ 그룹의 1명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다.'), + findsOneWidget, + ); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('이도윤')); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + // 넣은 주소는 다시 보이지 않는다 + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('사내'), findsNothing); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + }, + ); + + testWidgets('[다음]은 첫 추천을 넣는다 — 완전한 주소를 쳤으면 친 그대로', ( + WidgetTester tester, + ) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), 'doyun'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + + await tester.enterText( + field(RecipientFieldKind.to), + 'doyun.lee@other.example', + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('doyun.lee@other.example'), findsOneWidget); + }); + }); + + group('보내기', () { + testWidgets( + '보내면 시작한 화면으로 — "메일을 보냈습니다." · [보내기 취소] · 남은 시간 막대, 본문은 서식 + 텍스트', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text('김아라'), findsOneWidget); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(toast(MailComposeTexts.undoSend), findsOneWidget); + expect( + find.descendant( + of: find.byType(MailToastCard), + matching: find.byType(LinearProgressIndicator), + ), + findsOneWidget, + ); + + final SentRequest sent = c.sends.single; + expect(sent.key, matches(uuid)); + expect(sent.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(sent.body['subject'], '계약'); + expect(sent.body['body'], '안녕하세요\n\n김아라\n경영지원팀'); + expect( + sent.body['bodyHtml'], + contains( + '

안녕하세요


' + '

김아라

경영지원팀

', + ), + ); + expect(sent.body.containsKey('draftId'), isFalse); + // 쓰는 동안 저장할 틈(3초)이 없었다 + expect(c.saves, isEmpty); + + // 마감(서버 시각으로 10초 − 2초)이 지나면 닫힌다 + await tester.pump(const Duration(seconds: 8)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsNothing); + }, + ); + + testWidgets('[보내기 취소] — 발송을 취소하고 그 메일로 쓰기 화면을 다시 연다(서명까지 돌아온다)', ( + WidgetTester tester, + ) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + await tester.tap(toast(MailComposeTexts.undoSend)); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['sent-1']); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(snack(MailComposeTexts.undone), findsOneWidget); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, subjectField), '계약'); + expect(textOf(tester, bodyField), '안녕하세요'); + expect(find.byTooltip(MailComposeTexts.removeSignature), findsOneWidget); + + // 다시 보내면 새 키(새 양식)로, 이어 쓴 저장본 id 를 싣는다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.key, isNot(c.sends.first.key)); + expect(c.sends.last.body['draftId'], 'sent-1'); + }); + + testWidgets('바로 보냈으면(보내기 취소 0초) [보낸편지함 보기], 받지 못한 사람은 그 주소 · 까닭', ( + WidgetTester tester, + ) async { + c.undoSeconds = 0; + c.rejected = >[ + {'email': 'nobody@x.kr', 'reason': 'not-found'}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await addRecipient(tester, 'nobody@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(toast('보냈지만 1명은 받지 못했습니다. nobody@x.kr (없는 주소)'), findsOneWidget); + await tester.tap(toast(MailComposeTexts.viewSent)); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.mailbox(kSentId)); + }); + + testWidgets('답장을 보내면 읽기 화면으로 — 알림은 아래 막대 위 · 본문에 원문 인용 · inReplyTo', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '계약', isRead: true), + detail: {'textBody': '원문 줄'}, + ); + await start(tester); + await open(tester, MailRoutes.message('r1')); + await tester.tap(find.text(MailReaderTexts.reply)); + await tester.pumpAndSettle(); + await tester.enterText(bodyField, '확인했습니다'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageReaderPage), findsOneWidget); + final Rect card = tester.getRect(find.byType(MailToastCard)); + final Rect bar = tester.getRect(find.byType(ReaderActionBar)); + expect(card.bottom, lessThanOrEqualTo(bar.top - 12)); + + final Map body = c.sends.single.body; + expect(body['inReplyTo'], 'r1'); + expect(body['subject'], 'Re: 계약'); + expect( + body['body'], + startsWith('확인했습니다\n> 원본 메일\n> 보낸 사람: 이도윤 \n'), + ); + expect(body['body'], endsWith('> 제목: 계약\n>\n> 원문 줄')); + expect( + body['bodyHtml'], + contains( + '

원본 메일

', + ), + ); + }); + + testWidgets( + '서버가 받는 사람을 거절하면(MAIL_002) 알림 없이 칸 아래 띠, 본문이 길면(MAIL_007) 본문 아래 띠', + (WidgetTester tester) async { + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_002'), + FakeResponse.fail(400, 'MAIL_007'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '본문'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + + await tapTooltip(tester, MailComposeTexts.send); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_007')), findsOneWidget); + expect(focused(tester, bodyField), isTrue); + expect(h.notifier.shownCodes, isEmpty); + // 칸 안 실패는 같은 키로 다시 보낸다 + expect(c.sends[0].key, c.sends[1].key); + }, + ); + + testWidgets('그 밖의 실패는 알림 · 같은 키로 다시 보낸다, MAIL_005 뒤에는 새 키', ( + WidgetTester tester, + ) async { + c.sendFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(409, 'MAIL_005'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(h.notifier.shownCodes, ['MAIL_001']); + await tapTooltip(tester, MailComposeTexts.send); + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, hasLength(3)); + expect(c.sends[1].key, c.sends[0].key); + expect(c.sends[2].key, isNot(c.sends[1].key)); + expect(find.byType(MessageComposePage), findsNothing); + }); + + testWidgets('보내는 동안 떠나도 응답까지 기다려 알린다 — 떠난 뒤 실패하면 쓰던 내용을 저장본으로', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '첫 메일'); + final Completer gate = Completer(); + c.sendGate = gate; + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + gate.complete(); + await tester.pumpAndSettle(); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(c.saves, isEmpty); + + // 두 번째 — 떠난 뒤 받는 사람 거절(칸 안 실패) → 알림으로, 쓰던 내용은 저장본으로 + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'jihoon@x.kr'); + await tester.enterText(bodyField, '둘째 메일'); + final Completer second = Completer(); + c.sendGate = second; + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_002')); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + second.complete(); + await tester.pumpAndSettle(); + + expect(h.notifier.shownCodes, ['MAIL_002']); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '둘째 메일'); + expect(c.saves.single.body.containsKey('draftId'), isFalse); + }); + + testWidgets('웹에서 올리다 멈춘 대용량 파일이 있으면 보내지 않고 까닭을 알린다 — 빼면 보낸다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd5', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[ + { + 'id': 'u1', + 'status': 'uploading', + 'name': 'big.zip', + 'size': 31457280, + }, + { + 'id': 'u2', + 'status': 'ready', + 'name': 'ok.zip', + 'size': 1048576, + }, + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d5')); + + expect(find.text('big.zip'), findsOneWidget); + expect(find.text('30 MB · ${MailReaderTexts.largeFile}'), findsOneWidget); + expect(findCopy(MailComposeTexts.largeUploading), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.largeUploading), findsOneWidget); + expect(c.sends, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('big.zip')); + await tapTooltip(tester, MailComposeTexts.send); + final Map body = c.sends.single.body; + expect(body['largeFileIds'], ['u2']); + expect(body['draftId'], isNotNull); + }); + + testWidgets('저장본의 제목이 255자를 넘으면 보내지 않고 제목 아래 띠(VAL_001) · 제목으로 초점', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd6', + to: >[addressJson('박준호', 'junho@example.com')], + subject: 'a' * 300, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d6')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('VAL_001')), findsOneWidget); + expect(focused(tester, subjectField), isTrue); + }); + }); + + group('임시 저장 · 나가기', () { + testWidgets('이어 쓰기 — 칸 · 서명을 되살리고, 바꾸면 3초 뒤 PUT {id}. [서명 빼기] 뒤에는 [서명 넣기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + body: '쓰던 본문\n\n김아라', + bodyHtml: + '

쓰던 본문

' + '


김아라

', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '쓰던 메일'); + expect(textOf(tester, bodyField), '쓰던 본문'); + expect(find.text('김아라'), findsOneWidget); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.removeSignature); + expect(find.text('김아라'), findsNothing); + expect(find.text(MailComposeTexts.insertSignature), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'PUT d1'); + expect(c.saves.single.body['body'], '쓰던 본문'); + + await tester.tap(find.text(MailComposeTexts.insertSignature)); + await tester.pumpAndSettle(); + expect(find.text('김아라'), findsOneWidget); + + // 바꾼 것이 남은 채 닫으면 저장하고 닫는다 — 간격(10초)을 기다리지 않는다 + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.last.call, 'PUT saved-1'); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('서식이 있는 남의 저장본 — 안내 줄, 본문을 고치지 않으면 원래 HTML 그대로 저장한다', ( + WidgetTester tester, + ) async { + const String html = '

안녕 굵게

'; + c.putDraft( + 'd2', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '서식', + body: '안녕 굵게', + bodyHtml: html, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d2')); + + expect(findCopy(MailComposeTexts.foreignDraft), findsOneWidget); + expect(textOf(tester, bodyField), '안녕 굵게'); + + await tester.enterText(subjectField, '서식 고침'); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.body['bodyHtml'], html); + expect(c.saves.single.body['body'], '안녕 굵게'); + + await tester.enterText(bodyField, '안녕 굵게 더함'); + await tester.pump(const Duration(seconds: 10)); + expect(c.saves.last.body['bodyHtml'], isNot(html)); + expect(c.saves.last.body['body'], '안녕 굵게 더함'); + }); + + testWidgets('바꾼 것이 없으면 뒤로 가기에 바로 닫힌다(묻지 않고 알리지 않는다)', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(SnackBar), findsNothing); + expect(c.saves, isEmpty); + }); + + testWidgets('바꾼 채 뒤로 가기 — 쓰다 만 주소까지 칩으로 만들어 저장한 뒤 닫고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(field(RecipientFieldKind.to), 'doyun@x.kr'); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('저장하지 못하면 묻는다 — [계속 쓰기]가 주 단추, [나가기]는 알리지 않고 닫는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '잃을 수 있는 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, MailComposeTexts.leave), + findsOneWidget, + ); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(textOf(tester, bodyField), '잃을 수 있는 글'); + + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('5초 안에 저장이 끝나지 않아 물었는데 그사이 끝나면 창을 닫고 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '느린 저장'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('메일을 보는 직원이 바뀌면(로그아웃) 묻지도 저장하지도 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '로그아웃 전에 쓰던 글'); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 30)); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('앱이 안 보이게 되면 바꾼 것을 바로 저장한다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + await tester.pump(const Duration(milliseconds: 100)); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + }); + + testWidgets('저장할 때마다 바뀌는 id — 목록의 옛 줄을 눌러도 마지막 저장본이 열린다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd7', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '처음', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d7')); + await tester.enterText(subjectField, '고친 제목'); + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.single.call, 'PUT d7'); + expect(c.drafts.containsKey('d7'), isFalse); + + await open(tester, MailRoutes.composeDraft('d7')); + expect(textOf(tester, subjectField), '고친 제목'); + expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1)); + }); + }); +} diff --git a/flutter/test/features/mail/message_reader_page_test.dart b/flutter/test/features/mail/message_reader_page_test.dart index 0e3443f..ce69581 100644 --- a/flutter/test/features/mail/message_reader_page_test.dart +++ b/flutter/test/features/mail/message_reader_page_test.dart @@ -13,13 +13,13 @@ import 'package:arasystem_app/features/mail/domain/mail_html.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; -import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -94,7 +94,7 @@ void main() { String? lastDocument() => h.webView.last.lastHtml; String pushedLocation(WidgetTester tester) => GoRouterState.of( - tester.element(find.byType(PlaceholderPage).last), + tester.element(find.byType(MessageComposePage).last), ).uri.toString(); group('머리 · 본문', () { @@ -684,7 +684,7 @@ void main() { // 쓰기 화면 · 바깥 링크 · mailto 를 열지 않는다 await tester.tap(find.text(MailReaderTexts.reply)); await tester.pumpAndSettle(); - expect(find.byType(PlaceholderPage), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); expect( await h.webView.last.navigate('mailto:kim@example.com'), NavigationDecision.prevent, @@ -694,7 +694,7 @@ void main() { NavigationDecision.prevent, ); await tester.pumpAndSettle(); - expect(find.byType(PlaceholderPage), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); expect(h.links.launched, isEmpty); // 그사이 위에 다른 화면이 열렸다 — 옮기기가 끝나도 그 화면을 닫지 않는다 @@ -704,7 +704,7 @@ void main() { await tester.pumpAndSettle(); expect(h.mail.mailboxOf('m1'), kTrashId); - expect(find.byType(PlaceholderPage), findsOneWidget); + expect(find.byType(MessageComposePage), findsOneWidget); expect(pushedLocation(tester), MailRoutes.compose); h.router.pop(); await tester.pumpAndSettle(); @@ -1042,7 +1042,7 @@ void main() { ); await tester.pumpAndSettle(); final GoRouterState compose = GoRouterState.of( - tester.element(find.byType(PlaceholderPage).last), + tester.element(find.byType(MessageComposePage).last), ); expect(compose.uri.path, MailRoutes.compose); expect( diff --git a/flutter/test/support/compose_fakes.dart b/flutter/test/support/compose_fakes.dart new file mode 100644 index 0000000..2e048ff --- /dev/null +++ b/flutter/test/support/compose_fakes.dart @@ -0,0 +1,298 @@ +import 'dart:async'; + +import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; + +import 'fakes.dart'; +import 'mail_fakes.dart'; + +/// 받은 발송 하나 — 본문과 중복 발송 방지 키 +typedef SentRequest = ({Map body, String? key}); + +/// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 · +/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태. +/// +/// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와 +/// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다. +class FakeComposeBackend { + FakeComposeBackend(this.server, this.mail); + + final FakeServer server; + final FakeMailBackend mail; + + /// 저장본 — id 마다 계약의 `Draft` 칸 + final Map> drafts = + >{}; + + /// 받은 저장 요청 차례대로 — `POST` · `PUT {id}` 와 본문 + final List<({String call, Map body})> saves = + <({String call, Map body})>[]; + + /// 받은 발송 차례대로 + final List sends = []; + + /// 다음 저장들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List draftFailures = []; + + /// 다음 발송들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List sendFailures = []; + + /// 있으면 저장 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? saveGate; + + /// 있으면 발송 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? sendGate; + + /// 발송의 붙잡기 — 보내기 취소를 기다리는 초(0 이면 바로 보냈다) + int undoSeconds = 10; + + /// 받지 못한 사람 — 발송 응답의 `rejectedRecipients` + List> rejected = >[]; + + /// 내 서명 — null 이면 없음(404) + Map? signature; + + /// 서명 그림 — id 마다 바이트 + final Map> signatureImages = >{}; + + /// 받는 사람 추천 — 받아 두기(q 없음)와 묻기(q)에 같은 것을 준다(q 는 이름 · 주소에 든 것만) + List> suggestions = >[]; + + /// 받아 두기(q 없음)의 다음 쪽이 있다 — 있으면 쓰기 화면이 모자랄 때 서버에 q 로 묻는다 + bool suggestionsHasNext = false; + + /// 주소록 그룹과 그 구성원(`Contact` 꼴) + List> groups = >[]; + final Map>> members = + >>{}; + + /// 대용량 파일의 지금 상태 — id 마다(없으면 404) + final Map> largeFiles = + >{}; + + /// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? largeFileGate; + + int _nextDraft = 0; + int _nextSent = 0; + + static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$'); + static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$'); + static final RegExp _filePath = RegExp(r'^/file-uploads/([^/]+)$'); + + String _idOf(RegExp pattern, String path) => + Uri.decodeComponent(pattern.firstMatch(path)!.group(1)!); + + List get suggestionRequests => + server.received('GET', ComposeApiPaths.recipientSuggestions); + + /// 저장본 하나를 둔다 — 칸은 계약 2절 `Draft` + void putDraft( + String id, { + List> to = const >[], + List> cc = const >[], + List> bcc = const >[], + String subject = '쓰던 메일', + String body = '', + String? bodyHtml, + List> attachments = const >[], + String? inReplyTo, + List> largeFiles = const >[], + }) { + drafts[id] = { + 'id': id, + 'to': to, + 'cc': cc, + 'bcc': bcc, + 'subject': subject, + 'body': body, + 'bodyHtml': bodyHtml, + 'attachments': attachments, + 'inReplyTo': inReplyTo, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + 'largeFiles': largeFiles, + }; + } + + /// 경로마다 처리기를 건다 + void install() { + server.on('POST', ComposeApiPaths.drafts, (RequestOptions options) { + return _save('POST', options, null); + }); + server.onPattern('PUT', _draftPath, (RequestOptions options) { + final String id = _idOf(_draftPath, options.path); + return _save('PUT $id', options, id); + }); + server.onPattern('GET', _draftPath, (RequestOptions options) { + final Map? draft = + drafts[_idOf(_draftPath, options.path)]; + return draft == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(draft); + }); + server.on('POST', MailApiPaths.messages, _send); + server.on('GET', ComposeApiPaths.mailSignature, (_) { + final Map? current = signature; + return current == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(current); + }); + server.onPattern('GET', _imagePath, (RequestOptions options) { + final List? bytes = signatureImages[_idOf(_imagePath, options.path)]; + return bytes == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.bytes(bytes, contentType: 'image/png'); + }); + server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({'undoSendSeconds': undoSeconds}), + ); + server.on('GET', ComposeApiPaths.recipientSuggestions, ( + RequestOptions options, + ) { + final Object? q = options.queryParameters['q']; + final List> items = q is String + ? >[ + for (final Map item in suggestions) + if ('${item['name']} ${item['email']}'.contains(q)) item, + ] + : suggestions; + final bool hasNext = q is! String && suggestionsHasNext; + return FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'next' : null, + }, + }); + }); + server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': groups, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) { + final Object? groupId = options.queryParameters['groupId']; + final List>? list = members[groupId]; + if (list == null) return FakeResponse.fail(404, 'RES_001'); + return FakeResponse.ok({ + 'items': list, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }); + }); + server.onPattern('GET', _filePath, (RequestOptions options) async { + final Completer? gate = largeFileGate; + if (gate != null) { + largeFileGate = null; + await gate.future; + } + final Map? file = + largeFiles[_idOf(_filePath, options.path)]; + return file == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(file); + }); + } + + Future _save( + String call, + RequestOptions options, + String? previousId, + ) async { + final Map body = options.data! as Map; + saves.add((call: call, body: body)); + final Completer? gate = saveGate; + if (gate != null) { + saveGate = null; + await gate.future; + } + if (draftFailures.isNotEmpty) return draftFailures.removeAt(0); + if (previousId != null && !drafts.containsKey(previousId)) { + return FakeResponse.fail(404, 'RES_001'); + } + if (previousId != null) drafts.remove(previousId); + final String id = 'saved-${++_nextDraft}'; + final List attachments = [ + for (final Map item + in (body['attachments']! as List) + .cast>()) + attachmentJson('$id-${item['id']}', name: item['name']! as String), + ]; + drafts[id] = { + ...body, + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + 'largeFiles': [ + for (final Object? fileId + in (body['largeFileIds'] as List?) ?? []) + { + 'id': fileId, + 'status': 'ready', + 'name': '$fileId.zip', + 'size': 1, + }, + ], + }; + return FakeResponse.ok({ + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + }); + } + + Future _send(RequestOptions options) async { + final Map body = options.data! as Map; + sends.add(( + body: body, + key: options.headers[kIdempotencyHeader] as String?, + )); + final Completer? gate = sendGate; + if (gate != null) { + sendGate = null; + await gate.future; + } + if (sendFailures.isNotEmpty) return sendFailures.removeAt(0); + final Object? draftId = body['draftId']; + if (draftId is String) drafts.remove(draftId); + final String id = 'sent-${++_nextSent}'; + final DateTime now = server.serverTime ?? kTestNow; + // 보낸편지함에 넣어 둔다 — [보내기 취소]가 그 메일을 임시보관함으로 옮긴다 + mail.put( + kSentId, + messageJson( + id, + mailboxId: kSentId, + subject: body['subject']! as String, + pendingSend: undoSeconds > 0 + ? pendingJson(now.add(Duration(seconds: undoSeconds))) + : null, + ), + ); + if (undoSeconds > 0) { + // 취소하면 보낸 내용 그대로 임시 저장본이 된다 + drafts[id] = { + ...body, + 'id': id, + 'attachments': [], + 'updatedAt': now.toUtc().toIso8601String(), + 'largeFiles': [], + }..remove('draftId'); + } + return FakeResponse(201, { + 'success': true, + 'data': { + 'id': id, + 'rejectedRecipients': rejected, + 'pendingSend': undoSeconds > 0 + ? pendingJson(now.add(Duration(seconds: undoSeconds))) + : null, + }, + }); + } +} -- 2.52.0 From f39f41ed4b30a2a80d84a968c031eb49a3354ebb Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 02:11:01 +0900 Subject: [PATCH 11/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=99=94=EB=A9=B4=201=EC=B0=A8=EC=9D=98?= =?UTF-8?q?=20=EB=B3=B8=EB=AC=B8=20=EB=AA=A8=EC=96=91=EA=B3=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=98=EB=A9=B0=20=EC=A0=95=ED=95=9C=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 2절 "서식 본문"에 앱이 만드는 모양(서명 경계 div · 글로 만든 원문 인용 · 다시 열기 조건), 앱 디자인 README 「쓰기」에 구현하며 정한 것 · 알려진 한계 · 실기기 점검, flutter 스킬에 쓰기 화면 줄과 위젯 · 시험 함정을 적는다. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 7 +++++++ docs/design/app/README.md | 37 +++++++++++++++++++++++++++++++++ docs/mail/api-contract.md | 10 +++++++++ 3 files changed, 54 insertions(+) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 39a979c..79b6d8e 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -27,6 +27,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 | | **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` | | 받은 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `file_names.dart`(`safeFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다 | +| **메일 쓰기 (`App1Compose` — 1차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위) | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | @@ -290,6 +291,12 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. +- [ ] **위젯 · 시험 함정**(쓰기 화면에서 찾음 — 2026-11-07): + `TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다. + go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다. + `ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`). + 되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`). + 소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다. - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). - [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`). `.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다. diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 9af2319..928695f 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -120,6 +120,43 @@ - 보낸 뒤에는 쓰기를 시작한 화면(읽기 · 목록)으로 돌아가고, 그 화면 아래에 "메일을 보냈습니다. [보내기 취소]"와 남은 시간 막대. - 만들 때 지킬 것(2026-11-06 읽기 화면 리뷰): 읽기 화면의 mailto 링크로 채운 숨은참조가 있으면 숨은참조 칸을 펼쳐 보인다 — 링크가 숨은참조를 몰래 넣을 수 있다(RFC 6068 보안 고려). +- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 — 첨부 고르기 · 올리기는 2차) + - 본문: 글만 쓴다. 보낼 때 서식 본문 + 텍스트를 함께 만든다(계약 2절 "앱이 만드는 모양"). 원문 인용은 글로 만든 덩이라 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다(웹에서 쓴 답장은 원문 서식이 남는다 — 서식 편집을 넣는 다음 판에서 넓힌다). + 처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 내 글만으로 넘으면 본문 아래 띠(`MAIL_007`). + - 서명: 따로 된 칸(점선 위 · [서명 빼기] → 그 자리에 [서명 넣기] 한 줄). 새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만 넣는다. + 미리 보기는 글 줄과 서명 그림(Bearer · 300KB · HTML 폭을 화면 안으로)이고, 굵게 · 색 같은 꾸밈은 보낸 메일에만 남는다. + - 원본 메일: 처음 접혀 있다(요약 "보낸 사람 · 날짜" — 답장 · 전달은 "11월 5일 (목) …", 올해가 아니면 연도까지, 다시 연 저장본은 머리 표의 글 그대로). 펼치면 머리와 원문 줄, 원문 속 인용은 단마다 막대. + - 받는 사람 + - 칩은 쉼표 · 세미콜론 · 줄바꿈(따옴표 · 꺾쇠 밖) · [다음] · 칸 벗어남 · 추천 고름에서 만든다. [다음]은 첫 추천을 넣고, 완전한 주소면 친 그대로, 빈 칸이면 다음 칸으로 간다. + - 글 자판(추천 켬 · 자동 고침 끔)이다 — 메일 주소 자판은 한글을 막는다(로그인 화면에서 본 문제). + - 추천은 열 때 100개를 받아 두고 모자라면 250ms 뒤 q 로 묻는다. 묶음 이름 줄(주소록 · 그룹 · 사내 · 최근 주고받은 주소 — 계약 2절의 "사내와 최근을 나눠 보인다")을 두고 8줄까지다. + - 틀린 주소는 빨간 칩, 받는 사람 오류(사전 검사 · `MAIL_002` · `MAIL_010`)는 칸 아래 띠다. 참조 · 숨은참조는 값이 있어도 접을 수 있고(보드대로) 오류가 나면 저절로 펼친다. + 전체 답장 · 저장본 · mailto 에 값이 있으면 펼친 채 연다. 칩 ✕ 40 은 밀도 예외다. + - 제목: UTF-16 255자(서버 · 웹과 같은 단위 — 조합 중에는 막지 않고 끝나면 자른다). 저장본 · mailto 로 넘게 들어온 제목은 보낼 때 띠로 막고 제목에 초점을 옮긴다. + - 열 때 초점: 받는 사람 → 제목 → 본문 가운데 비어 있는 첫 칸(답장은 본문). + - 임시 저장: 웹 규칙(3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초에 한 번 · 한 번에 하나 · `MAIL_008` · 404 면 새로 · 실패면 15 · 30 · 60초 뒤 다시)에 더해, 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바로 저장한다. + 쓰는 동안 저장 상태는 보이지 않고, 저장한 채 닫으면 "임시보관함에 저장했습니다." 옛 저장본 id → 새 id 를 세션 동안 기억한다(목록의 옛 줄 → 마지막 저장본). + - 저장본 읽기: 읽기 화면과 같은 한도 · 문턱(그 자리 · 다른 isolate · 읽지 않음). 한도를 넘은 저장본은 텍스트 본문으로 열고 원래 HTML 을 그대로 다시 보낸다. 읽다 실패하면 [다시 시도]. + 서식이나 그림이 있는 남의 저장본(웹의 정렬 · 들여쓰기 포함)은 "서식이나 그림이 있는 임시 저장본입니다. 앱에서 본문을 고치면 글자만 남으니, 서식을 지키려면 웹메일에서 이어 써 주세요." + - 보내기: 칩 확정 → 사전 검사 → 본문 길이 재기 → 진행 중 저장 기다림 → 발송(60초 · 같은 키 — 성공 · `MAIL_005` 에만 새 키). + 보내는 동안은 칸 · 단추를 막는다(✕ · 뒤로는 그대로 — [보내기] 자리는 도는 표시, 낭독 "보내는 중입니다."). + [보내기]를 누른 뒤 떠나도 저장을 기다려 그대로 보내고 결과를 알린다(웹과 같다 — 2026-11-07 리뷰: 기다리는 사이 화면이 내려가면 나가지 않았다). + 떠난 뒤 보내기 전에 멈추면(기다린 저장이 첨부를 찾지 못함 · 대용량 파일을 쓸 수 없음) `MAIL_004` · `MAIL_016` 의 사전 문구를 알리고, 저장할 수 있으면 쓰던 내용을 저장본으로 남긴다. + - 보낸 뒤: 시작한 화면(읽기 · 목록)으로 돌아가 "메일을 보냈습니다." + [보내기 취소] + 3px 남은 시간 막대(읽기 화면에도 아래 막대 위 12 에 알림 자리를 만들었다). + 마감은 서버 시각이 있으면 sendAt − Date − 2초(Date 가 초 아래를 버리고 응답 시간을 빼지 않아 1초 더 남긴다), 없으면 min(설정 초 − 1초 — 모르면 30초, sendAt − 기기 시각), 상한 30초. + 화면 낭독기를 켠 동안에도 마감에 닫는다. 바로 보냈으면(설정 0) [보낸편지함 보기]. [보내기 취소]는 쓰기 화면을 다시 열고(새 키 · 서명 칸 · 인용까지) "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 받는 사람 일부가 받지 못했으면 웹 문장 그대로("보냈지만 N명은 받지 못했습니다. …"). + - 나가기: 잃을 것이 없으면 바로 닫는다(예측 뒤로 가기). 있으면 쓰다 만 주소를 확정하고 저장(5초)한다 — 저장했으면 "임시보관함에 저장했습니다."로 닫고, + 못 했으면 "저장하지 못한 내용이 있습니다" / "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]. + 창을 연 뒤 저장이 끝나 잃을 것이 없어지면 저절로 닫고 떠난다. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다. 보내는 중에는 묻지 않고, 세션이 끝나면 창만 닫고 알리지 않는다. + - 첨부(1차): 전달하는 원본의 첨부와 저장본에 있던 첨부 · 대용량 파일을 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다 + (함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일). [파일 첨부]는 "앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요." 첨부 머리의 한도 글은 2차다. + - 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12). + - **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다. + 첨부 합계 한도는 1차에서 25MB 대체값이다(`mail-quota` 는 2차). **푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다. + - **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤, + 서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림. + ### 새 메일 알림 (푸시) - 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 7e0169a..b1c73c9 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -480,6 +480,16 @@ interface RejectedRecipient { 원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다. - **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로). 서명 그림만 예외다 (아래 "서명"). +- **앱이 만드는 모양**(2026-11-07 — 앱 쓰기 화면은 글만 쓴다. 웹이 다시 열 때 인용을 알아보는 모양이다): + - 감싸개는 위 기본 글꼴 div 이고, 내 글은 줄마다 `

`(빈 줄은 `


`, 여러 칸 공백 · 줄 앞 공백 · 탭은 위 nbsp 규칙)이다. + - 서명이 있으면 빈 문단 하나 뒤의 **속성 없는 `
` 하나**가 서명 경계다 — 안은 서버가 준 서명 HTML 이고, margin 이 없는 `p` 에만 `margin: 0` 을 앞에 붙인다. + - 원문 인용은 웹과 같은 "원본 메일" 머리 문단 + 머리 표(답장은 보낸 사람 · 날짜 · 제목, 전달은 받는 사람 · 참조도 — 날짜는 "2026. 11. 5. (목) 오전 10:42")에 + **글로 만든 원문 줄**(`

` — 원문 속 인용은 안쪽 blockquote, 링크는 "글 (주소)")이다. 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다. + 원문이 길면 처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 줄였으면 끝에 "(원문이 길어 이하 생략)" 문단을 둔다. 인용 경계 표시는 대용량 파일이 있을 때만이다(4절). + - 쓴 글 · 서명 · 인용이 모두 없으면 `bodyHtml` 을 싣지 않는다(텍스트 메일). `body` 는 내 글 줄 + (서명이 있으면 빈 줄 + 텍스트 서명) + 인용 줄(웹 `docToText` 와 같은 꼴 — "> 원본 메일" · "> 항목: 값" · ">" · "> 원문" · "> > 안쪽")이다. + - **다시 열기**: 감싸는 div(속성 없음 또는 앱 글꼴 style) 아래가 "글 · br 만 든 문단(속성 없음 또는 style 이 `margin:0` 하나 — 공백 · 끝 `;` 를 빼고 견줌) → (속성 없는 div 하나 = 서명) → (빈 div) → (원본 메일 blockquote)"이면 칸마다 되살린다. + 그 밖(웹의 정렬 · 들여쓰기 · 꾸밈 · 다른 프로그램의 저장본)은 인용 앞부분을 글로 바꿔 본문 칸에 넣고 안내 줄을 보이며, 본문을 고치지 않으면 원래 HTML · 텍스트를 그대로 다시 보낸다. + 읽기 화면과 같은 한도(서식 본문 2,000,000자 · 중첩 비용 8,000,000)를 넘는 저장본은 읽지 않고 텍스트 본문으로 연다. ### 첨부 업로드 한도 -- 2.52.0 From 3a126bdf832ab59780286cf212274ae44d79b7f2 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 04:19:57 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=99=94=EB=A9=B4=202=EC=B0=A8=20?= =?UTF-8?q?=E2=80=94=20=EC=B2=A8=EB=B6=80=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=98=AC=EB=A6=AC=EA=B8=B0=EB=A5=BC=20=EB=8D=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에 복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를 찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고. - 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota - 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시) - 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다) - MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다 - 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다 - 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다 리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750. Co-Authored-By: Claude Opus 5.5 --- .../kr/it/arasystem/app/MainActivity.kt | 428 ++++++++- flutter/lib/core/files/file_names.dart | 23 +- flutter/lib/core/files/picked_files.dart | 330 +++++++ flutter/lib/core/utils/file_size.dart | 13 +- flutter/lib/core/utils/korean_text.dart | 11 +- .../mail/data/models/compose_models.dart | 41 +- .../data/repositories/compose_repository.dart | 70 +- .../mail/domain/compose_attachments.dart | 136 +++ .../features/mail/domain/compose_send.dart | 43 +- .../mail/presentation/mail_texts.dart | 60 +- .../pages/message_compose_page.dart | 86 +- .../providers/attachment_uploads.dart | 214 +++++ .../providers/compose_controller.dart | 415 +++++++- .../presentation/providers/compose_state.dart | 132 ++- .../presentation/widgets/compose_bars.dart | 2 +- .../widgets/compose_sections.dart | 295 ++++-- flutter/lib/main.dart | 7 +- .../test/core/files/picked_files_test.dart | 338 +++++++ .../mail/attachment_uploads_test.dart | 203 ++++ .../mail/compose_attachments_test.dart | 361 +++++++ .../mail/compose_repository_test.dart | 201 ++++ .../features/mail/compose_rules_test.dart | 1 + .../mail/message_compose_page_test.dart | 883 +++++++++++++++++- flutter/test/support/compose_fakes.dart | 81 +- flutter/test/support/fakes.dart | 13 + flutter/test/support/file_fakes.dart | 101 ++ flutter/test/support/mail_harness.dart | 8 +- 27 files changed, 4360 insertions(+), 136 deletions(-) create mode 100644 flutter/lib/core/files/picked_files.dart create mode 100644 flutter/lib/features/mail/domain/compose_attachments.dart create mode 100644 flutter/lib/features/mail/presentation/providers/attachment_uploads.dart create mode 100644 flutter/test/core/files/picked_files_test.dart create mode 100644 flutter/test/features/mail/attachment_uploads_test.dart create mode 100644 flutter/test/features/mail/compose_attachments_test.dart diff --git a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt index a550c2c..7464f20 100644 --- a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/MainActivity.kt @@ -1,22 +1,52 @@ package kr.it.arasystem.app +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.ContentResolver import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.File +import java.io.FileOutputStream import java.io.IOException +import java.security.SecureRandom +import java.util.Locale /** * 앱 화면 하나 — Flutter 가 그린다. * - * 파일 채널(`kr.it.arasystem.app/files`): 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의 - * `PlatformFileOpener` 가 부른다. 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다. - * 결과는 글 하나 — opened · noApp · notFound · failed. 경로 · 주소는 로그에 남기지 않는다. + * 파일 채널(`kr.it.arasystem.app/files`) — 권한 · 새 플러그인 없이 앱 임시 폴더 두 곳만 쓴다: + * - `open`: 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의 `PlatformFileOpener` 가 부른다. + * 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다. + * 결과는 글 하나 — opened · noApp · notFound · failed. + * - `pick`: 고르는 창(`ACTION_GET_CONTENT` — 모든 형식 · 여러 개)으로 고른 파일 가운데 넣을 파일만 앱 임시 폴더의 [UPLOADS_DIR] 에 복사한다 — + * Dart `core/files/picked_files.dart` 의 `PlatformPickedFileStore` 가 부른다. 고르는 창에서는 형식으로 거르지 않는다(hwp 가 숨는다). + * 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다. + * 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`. + * 경로 · 주소 · 파일 이름은 로그에 남기지 않는다. */ class MainActivity : FlutterActivity() { + /** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */ + private var pendingPick: PendingPick? = null + + /** 고르기 한 번의 한도 — Dart 가 넘긴다(짝: `PlatformPickedFileStore.pick` 의 인자) */ + private class PickLimits( + /** 파일 하나의 한도 — 그 + 1바이트를 넘으면 복사를 멈춘다 */ + val maxBytes: Long, + /** 넣을 수 있는 남은 개수 */ + val maxCount: Int, + /** 넣을 수 있는 남은 합계 바이트 */ + val maxTotalBytes: Long, + /** 허용 확장자(소문자, 점 없이) — Dart 의 형식 표 */ + val extensions: Set, + ) + + private class PendingPick(val result: MethodChannel.Result, val limits: PickLimits) + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL) @@ -25,6 +55,15 @@ class MainActivity : FlutterActivity() { OPEN_METHOD -> result.success( openFile(call.argument("path"), call.argument("type")), ) + PICK_METHOD -> pickFiles( + limitsOf( + call.argument(ARG_MAX_BYTES), + call.argument(ARG_MAX_COUNT), + call.argument(ARG_MAX_TOTAL_BYTES), + call.argument>(ARG_EXTENSIONS), + ), + result, + ) else -> result.notImplemented() } } @@ -71,14 +110,354 @@ class MainActivity : FlutterActivity() { return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE } + // ── 고르기 ───────────────────────────────────────── + + /** 넘겨받은 한도 — 모양이 틀리면 null */ + private fun limitsOf(maxBytes: Number?, maxCount: Number?, maxTotalBytes: Number?, extensions: List<*>?): PickLimits? { + if (maxBytes == null || maxCount == null || maxTotalBytes == null || extensions == null) return null + if (maxBytes.toLong() < 0 || maxCount.toInt() < 1 || maxTotalBytes.toLong() < 0) return null + return PickLimits( + maxBytes = maxBytes.toLong(), + maxCount = maxCount.toInt(), + maxTotalBytes = maxTotalBytes.toLong(), + extensions = extensions.filterIsInstance().map { it.lowercase(Locale.ROOT) }.toSet(), + ) + } + + /** + * 고르는 창을 연다 — 권한이 필요 없다(고르는 창이 고른 파일의 읽기 권한만 준다). 답은 고른 뒤 [onActivityResult] 에서 한다 + */ + private fun pickFiles(limits: PickLimits?, result: MethodChannel.Result) { + if (limits == null) { + result.success(outcomeOf(OUTCOME_FAILED)) + return + } + if (pendingPick != null) { + result.success(outcomeOf(OUTCOME_BUSY)) + return + } + // 형식으로 거르지 않는다 — hwp 는 기기 · 앱마다 형식이 달라 창에서 거르면 숨는다(고른 뒤 확장자로 거른다) + val intent = Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(ANY_TYPE) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + try { + startActivityForResult(intent, PICK_REQUEST_CODE) + } catch (error: ActivityNotFoundException) { + result.success(outcomeOf(OUTCOME_NO_APP)) + return + } + pendingPick = PendingPick(result, limits) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != PICK_REQUEST_CODE) { + super.onActivityResult(requestCode, resultCode, data) + return + } + // 고르는 동안 앱이 정리됐다가 다시 떴으면 기다리는 답이 없다 — 복사하지 않는다(Dart 의 쓰기 화면도 없다) + val pending = pendingPick ?: return + pendingPick = null + val uris = if (resultCode == Activity.RESULT_OK && data != null) pickedUris(data) else emptyList() + if (uris.isEmpty()) { + pending.result.success(outcomeOf(OUTCOME_CANCELED)) + return + } + // 복사는 화면 스레드 밖에서 — 클라우드 파일은 읽는 동안 받는다. 답은 화면 스레드에서 한 번만 + Thread { + val files = ArrayList>() + val reply: Map = try { + copyPicked(uris, pending.limits, files) + mapOf(KEY_OUTCOME to OUTCOME_PICKED, KEY_FILES to files) + } catch (error: Throwable) { + // 뜻밖의 실패(권한 · 메모리 등) — 만든 복사본을 지우고 실패로 답한다(Dart 가 기다리며 멈춰 있지 않게) + for (file in files) { + (file[KEY_PATH] as? String)?.let { deleteCopy(it) } + } + outcomeOf(OUTCOME_FAILED) + } + runOnUiThread { pending.result.success(reply) } + }.start() + } + + /** 고른 주소 — 여러 개면 clipData, 하나면 data */ + private fun pickedUris(data: Intent): List { + val clip = data.clipData + if (clip != null && clip.itemCount > 0) { + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + } + return listOfNotNull(data.data) + } + + /** + * 고른 차례대로 넣을 파일만 복사한다 — **차례는 Dart 사전 검사(`compose_attachments.dart` 의 `precheckPickedFile`)와 같다**: + * 개수(넣은 수가 [PickLimits.maxCount]) → 형식(확장자 — 짝: Dart `displayFileName` · `extensionOf`) → 파일당(알려 준 크기) → + * 합계(알려 준 크기로 먼저, 복사한 실제 크기로 다시 — 넘으면 그 복사본을 지운다). 넣지 않을 파일은 읽지 않는다. + * 센 것은 넣을 파일뿐이다 — 형식 · 크기 · 합계 · 읽지 못함으로 빠진 파일은 개수 · 합계에 들지 않는다(웹과 같다) + */ + private fun copyPicked(uris: List, limits: PickLimits, files: MutableList>) { + val root = File(cacheDir, UPLOADS_DIR) + var count = 0 + var bytes = 0L + for (uri in uris) { + // content:// 만 읽는다 — 고른 앱이 file:// 로 우리 앱 안 파일을 건네는 길을 닫는다(묻지도 읽지도 않는다). + // 이름은 주소의 마지막 칸으로만 — Dart 가 "가져오지 못함"(형식이 틀리면 형식)으로 알린다 + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + files.add(fileOf(uri.lastPathSegment, null, STATUS_FAILED, null)) + continue + } + val info = queryInfo(uri) + val name = info.first + val reported = info.second + val file = when { + count >= limits.maxCount -> fileOf(name, reported, STATUS_SKIPPED, null) + extensionOf(displayFileName(name)) !in limits.extensions -> fileOf(name, reported, STATUS_NOT_ALLOWED, null) + reported != null && reported > limits.maxBytes -> fileOf(name, reported, STATUS_TOO_LARGE, null) + reported != null && bytes + reported > limits.maxTotalBytes -> fileOf(name, reported, STATUS_OVER_TOTAL, null) + else -> withinTotal(copyOne(uri, name, reported, limits.maxBytes, root), name, bytes, limits) + } + if (file[KEY_STATUS] == STATUS_OK) { + count++ + bytes += file[KEY_SIZE] as Long + } + files.add(file) + } + } + + /** 복사한 실제 크기로 합계를 다시 본다 — 고른 앱이 크기를 알려 주지 않았거나 틀렸을 수 있다. 넘으면 복사본을 지운다 */ + private fun withinTotal(copied: Map, name: String?, bytes: Long, limits: PickLimits): Map { + if (copied[KEY_STATUS] != STATUS_OK) return copied + val size = copied[KEY_SIZE] as Long + if (bytes + size <= limits.maxTotalBytes) return copied + (copied[KEY_PATH] as? String)?.let { deleteCopy(it) } + return fileOf(name, size, STATUS_OVER_TOTAL, null) + } + + /** 보이는 이름 · 크기 — 고른 앱이 알려 주지 않으면 null */ + private fun queryInfo(uri: Uri): Pair { + var name: String? = null + var size: Long? = null + try { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex) + } + } + } catch (error: Exception) { + // 고른 앱마다 던지는 것이 다르다(권한 · 주소 모양) — 이름 · 크기 없이 본다(이름이 없으면 형식으로 빠진다) + } + return Pair(name, size?.takeIf { it >= 0 }) + } + + /** + * 하나를 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사한다 — [maxBytes] + 1바이트를 넘으면 멈추고 쓰다 만 복사본을 지운다. + * 자리는 열기 채널처럼 canonical 경로로 그 폴더 안인지 다시 본다 + */ + private fun copyOne(uri: Uri, name: String?, reported: Long?, maxBytes: Long, root: File): Map { + val folder = File(root, randomFolderName()) + val target = File(folder, safeFileName(name)) + try { + if (!target.canonicalPath.startsWith(root.canonicalPath + File.separator)) { + return fileOf(name, reported, STATUS_FAILED, null) + } + } catch (error: IOException) { + return fileOf(name, reported, STATUS_FAILED, null) + } + if (!folder.mkdirs() && !folder.isDirectory) return fileOf(name, reported, STATUS_FAILED, null) + return try { + var total = 0L + var tooLarge = false + val input = contentResolver.openInputStream(uri) + ?: throw IOException("열 수 없음") + input.use { source -> + FileOutputStream(target).use { sink -> + val buffer = ByteArray(COPY_BUFFER_BYTES) + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > maxBytes) { + tooLarge = true + break + } + sink.write(buffer, 0, read) + } + } + } + if (tooLarge) { + folder.deleteRecursively() + fileOf(name, reported, STATUS_TOO_LARGE, null) + } else { + fileOf(name, total, STATUS_OK, target.path) + } + } catch (error: Exception) { + // 읽기 · 쓰기 실패(클라우드 파일을 받지 못함 · 권한 · 저장 공간) — 쓰다 만 복사본을 지운다 + folder.deleteRecursively() + fileOf(name, reported, STATUS_FAILED, null) + } + } + + /** 복사본 하나를 그 폴더째 지운다 */ + private fun deleteCopy(path: String) { + File(path).parentFile?.deleteRecursively() + } + + private fun fileOf(name: String?, size: Long?, status: String, path: String?): Map = + mapOf(KEY_NAME to name, KEY_SIZE to size, KEY_STATUS to status, KEY_PATH to path) + + private fun outcomeOf(outcome: String): Map = mapOf(KEY_OUTCOME to outcome) + + /** 복사본마다 새 폴더 — 같은 이름을 여러 번 골라도 덮지 않는다(16진수 16자 — 지역 설정과 상관없이 0-9a-f) */ + private fun randomFolderName(): String { + val bytes = ByteArray(FOLDER_NAME_BYTES) + random.nextBytes(bytes) + val name = StringBuilder() + for (byte in bytes) { + val value = byte.toInt() and 0xff + name.append(HEX_DIGITS[value shr 4]).append(HEX_DIGITS[value and 0x0f]) + } + return name.toString() + } + + // ── 이름 · 확장자 (짝: Dart `core/files/file_names.dart` · `features/mail/domain/compose_attachments.dart`) ── + + /** + * 보이는 이름 — **짝: Dart `displayFileName`** 과 같은 규칙. 경로 앞부분(`/` · `\` 의 마지막 뒤)만 두고 제어 · 방향 제어 · 보이지 않는 글자를 지운 뒤 + * 앞뒤 공백(Dart `trim()` 의 공백 — [isDartWhitespace])을 지운다. 비면 [FALLBACK_FILE_NAME]. 형식을 가를 때만 쓴다(Dart 가 다시 정리한다) + */ + private fun displayFileName(raw: String?): String { + if (raw == null) return FALLBACK_FILE_NAME + val slash = maxOf(raw.lastIndexOf('/'), raw.lastIndexOf('\\')) + val segment = if (slash == -1) raw else raw.substring(slash + 1) + val cleaned = StringBuilder() + forEachCodePoint(segment) { codePoint -> + if (!isControl(codePoint) && !isInvisibleFormat(codePoint)) cleaned.appendCodePoint(codePoint) + } + val result = cleaned.toString().trim { isDartWhitespace(it.code) } + return result.ifEmpty { FALLBACK_FILE_NAME } + } + + /** 확장자 — **짝: Dart `extensionOf`**(마지막 점 뒤, 소문자 — 없으면 빈 글) */ + private fun extensionOf(name: String): String { + val dot = name.lastIndexOf('.') + return if (dot == -1) "" else name.substring(dot + 1).lowercase(Locale.ROOT) + } + + /** + * 기기 안 복사본의 이름 — 고른 앱이 정한 글이라 믿지 않는다. **짝: Dart `safeFileName`** 과 같은 규칙 + * (제어 · 방향 제어 · 보이지 않는 글자를 지우고, 경로 · 다른 시스템이 받지 않는 글자는 밑줄, 앞뒤 공백 · 점을 지우고, UTF-8 200바이트 — 확장자는 남긴다). + * 이 이름은 복사본에만 쓴다 — 보이는 이름 · 올릴 때 보내는 이름은 Dart 의 `displayFileName` + */ + private fun safeFileName(raw: String?): String { + val cleaned = StringBuilder() + forEachCodePoint(raw ?: "") { codePoint -> + if (isControl(codePoint) || isInvisibleFormat(codePoint)) return@forEachCodePoint + if (codePoint < 0x80 && REPLACED_CHARACTERS.indexOf(codePoint.toChar()) >= 0) { + cleaned.append('_') + } else { + cleaned.appendCodePoint(codePoint) + } + } + // Dart 의 trim() 뒤 `^[.\s]+|[.\s]+$` 지우기와 같다 — 앞뒤의 공백 · 점을 지운다 + val result = cleaned.toString().trim { it == '.' || isDartWhitespace(it.code) } + if (result.isEmpty()) return FALLBACK_FILE_NAME + return fitBytes(result, MAX_FILE_NAME_BYTES) + } + + private inline fun forEachCodePoint(text: String, action: (Int) -> Unit) { + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + index += Character.charCount(codePoint) + action(codePoint) + } + } + + /** 제어 문자(C0 · DEL · C1) — 짝: Dart `_isControl` */ + private fun isControl(codePoint: Int): Boolean = codePoint <= 0x1F || codePoint in 0x7F..0x9F + + /** 방향 제어 · 보이지 않는 글자 — 짝: Dart `_isInvisibleFormat` */ + private fun isInvisibleFormat(codePoint: Int): Boolean = + codePoint == 0x061C || + codePoint in 0x200B..0x200F || + codePoint in 0x202A..0x202E || + codePoint in 0x2060..0x2069 || + codePoint == 0xFEFF + + /** + * Dart `String.trim()` 이 지우는 공백(Unicode White_Space + BOM) — Kotlin 의 `trim()` 과 몇 글자가 달라 직접 적는다. + * Dart `RegExp` 의 `\s`(`safeFileName` 의 점 지우기)와도 제어 문자를 뺀 나머지가 같다 + */ + private fun isDartWhitespace(codePoint: Int): Boolean = + codePoint in 0x09..0x0D || + codePoint == 0x20 || + codePoint == 0x85 || + codePoint == 0xA0 || + codePoint == 0x1680 || + codePoint in 0x2000..0x200A || + codePoint == 0x2028 || + codePoint == 0x2029 || + codePoint == 0x202F || + codePoint == 0x205F || + codePoint == 0x3000 || + codePoint == 0xFEFF + + /** UTF-8 [maxBytes] 안으로 — 확장자(점 뒤 16자까지 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 */ + private fun fitBytes(name: String, maxBytes: Int): String { + if (name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name + val dot = name.lastIndexOf('.') + val extension = if (dot > 0 && + name.length - dot - 1 <= MAX_EXTENSION_LENGTH && + EXTENSION_PATTERN.matches(name.substring(dot + 1)) + ) { + name.substring(dot) + } else { + "" + } + val base = if (extension.isEmpty()) name else name.substring(0, dot) + val budget = maxBytes - extension.toByteArray(Charsets.UTF_8).size + val kept = StringBuilder() + var used = 0 + var index = 0 + while (index < base.length) { + val codePoint = base.codePointAt(index) + val size = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8).size + if (used + size > budget) break + kept.appendCodePoint(codePoint) + used += size + index += Character.charCount(codePoint) + } + val trimmed = kept.toString().trimEnd() + return if (trimmed.isEmpty()) FALLBACK_FILE_NAME + extension else trimmed + extension + } + private companion object { - /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` */ + /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` · `PlatformPickedFileStore.pickMethod` */ const val FILES_CHANNEL = "kr.it.arasystem.app/files" const val OPEN_METHOD = "open" + const val PICK_METHOD = "pick" + + /** `pick` 의 인자 — 짝: Dart `PlatformPickedFileStore.pick` */ + const val ARG_MAX_BYTES = "maxBytes" + const val ARG_MAX_COUNT = "maxCount" + const val ARG_MAX_TOTAL_BYTES = "maxTotalBytes" + const val ARG_EXTENSIONS = "extensions" /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */ const val DOWNLOADS_DIR = "downloads" + /** 짝: Dart `kPickedFilesFolder` — 다른 앱에 건네지 않아 FileProvider 경로(xml)에는 없다 */ + const val UPLOADS_DIR = "uploads" + /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */ const val AUTHORITY_SUFFIX = ".downloads" @@ -90,5 +469,46 @@ class MainActivity : FlutterActivity() { const val RESULT_NO_APP = "noApp" const val RESULT_NOT_FOUND = "notFound" const val RESULT_FAILED = "failed" + + /** 고르는 창의 요청 번호 — 다른 플러그인의 번호와 겹치지 않게 앱 이름(AR)에서 */ + const val PICK_REQUEST_CODE = 0x4152 + + /** 고르는 창에 보일 형식 — 모두 */ + const val ANY_TYPE = "*/*" + + // 짝: Dart `PickResult.parse` · `PickedFile.fromChannel` 의 칸과 글 + const val KEY_OUTCOME = "outcome" + const val KEY_FILES = "files" + const val KEY_NAME = "name" + const val KEY_SIZE = "size" + const val KEY_STATUS = "status" + const val KEY_PATH = "path" + const val OUTCOME_PICKED = "picked" + const val OUTCOME_CANCELED = "canceled" + const val OUTCOME_BUSY = "busy" + const val OUTCOME_NO_APP = "noApp" + const val OUTCOME_FAILED = "failed" + const val STATUS_OK = "ok" + const val STATUS_SKIPPED = "skipped" + const val STATUS_NOT_ALLOWED = "notAllowed" + const val STATUS_TOO_LARGE = "tooLarge" + const val STATUS_OVER_TOTAL = "overTotal" + const val STATUS_FAILED = "failed" + + /** 복사할 때 한 번에 읽는 크기 */ + const val COPY_BUFFER_BYTES = 64 * 1024 + + /** 복사본마다 만드는 폴더 이름의 바이트(16진수 16자 — Dart 받은 파일 폴더와 같은 길이) */ + const val FOLDER_NAME_BYTES = 8 + const val HEX_DIGITS = "0123456789abcdef" + + // 짝: Dart `kFallbackFileName` · `kMaxFileNameBytes` · `_maxExtensionLength` · `_replacedCharacters`(`file_names.dart`) + const val FALLBACK_FILE_NAME = "첨부파일" + const val MAX_FILE_NAME_BYTES = 200 + const val MAX_EXTENSION_LENGTH = 16 + const val REPLACED_CHARACTERS = "/\\:*?\"<>|" + val EXTENSION_PATTERN = Regex("^[A-Za-z0-9]+$") + + val random = SecureRandom() } } diff --git a/flutter/lib/core/files/file_names.dart b/flutter/lib/core/files/file_names.dart index 946d263..eaa4954 100644 --- a/flutter/lib/core/files/file_names.dart +++ b/flutter/lib/core/files/file_names.dart @@ -1,4 +1,4 @@ -/// 받은 파일을 기기에 둘 때의 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// 받은 파일을 기기에 둘 때의 이름 · 고른 파일의 보이는 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). /// /// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다 /// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다. @@ -29,7 +29,7 @@ bool _isInvisibleFormat(int rune) => /// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자 const String _replacedCharacters = r'/\:*?"<>|'; -/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback] +/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]. **짝: `MainActivity.kt` 의 `safeFileName`**(고른 파일의 복사본 이름) String safeFileName(String name, {String fallback = kFallbackFileName}) { final StringBuffer cleaned = StringBuffer(); for (final int rune in name.runes) { @@ -44,6 +44,25 @@ String safeFileName(String name, {String fallback = kFallbackFileName}) { return _fitBytes(result, kMaxFileNameBytes); } +/// 고른 파일의 보이는 이름 — 고르는 창의 앱이 알려 준 이름(`OpenableColumns.DISPLAY_NAME`)에서 경로 앞부분 · 제어 문자 · +/// 방향 제어 · 보이지 않는 글자를 지우고 앞뒤 공백을 지운다. 비면 [kFallbackFileName]. +/// +/// 다른 글자(`:` 등)와 길이는 그대로 둔다 — 서버가 받은 이름을 같은 결로 다시 정리한다(`shared/utils/file-name.ts` 의 `sanitizeFileName` — +/// 웹이 보내는 파일 이름과 같게). 기기 안 복사본의 이름은 따로 정리한다([safeFileName] · `MainActivity.kt` 의 `safeFileName`). +/// **짝: `MainActivity.kt` 의 `displayFileName`** — 고를 때 기기가 이 이름의 확장자로 형식을 미리 거른다(공백은 Dart `trim()` 의 공백) +String displayFileName(String? name) { + if (name == null) return kFallbackFileName; + final int slash = name.lastIndexOf(RegExp(r'[/\\]')); + final String segment = slash == -1 ? name : name.substring(slash + 1); + final StringBuffer cleaned = StringBuffer(); + for (final int rune in segment.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + cleaned.writeCharCode(rune); + } + final String result = cleaned.toString().trim(); + return result.isEmpty ? kFallbackFileName : result; +} + /// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 String _fitBytes(String name, int maxBytes) { if (utf8.encode(name).length <= maxBytes) return name; diff --git a/flutter/lib/core/files/picked_files.dart b/flutter/lib/core/files/picked_files.dart new file mode 100644 index 0000000..498650c --- /dev/null +++ b/flutter/lib/core/files/picked_files.dart @@ -0,0 +1,330 @@ +/// 고른 파일 — 안드로이드 고르는 창(`ACTION_GET_CONTENT`)으로 고른 파일을 앱 임시 폴더에 복사해 두고, 거기서 읽어 올린다(모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭(받은 파일 열기와 같다 — `downloaded_files.dart`): 파일 고르는 플러그인은 저장소 · 사진 권한이나 +/// 기기 전체를 가리키는 경로를 더한다. 이 길은 권한 없이 고른 파일만 읽는다(고르는 창이 그 파일의 읽기 권한을 준다 — `content://` 만 읽는다). +/// 고르는 창에는 모든 파일을 보인다(`*/*` — hwp 는 기기마다 형식이 달라 창에서 거르면 숨는다). +/// 고른 뒤 기기가 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계)로 쓰는 쪽의 사전 검사와 **같은 차례**로 미리 걸러 넣을 파일만 복사한다 — +/// 받지 않을 형식 · 합계를 넘는 파일을 내려받거나 쌓아 두지 않게. 판정은 쓰는 쪽(Dart)이 다시 한다. +/// +/// 복사본을 두는 까닭: 고른 주소(content://)의 읽기 권한은 그 화면(Activity)이 있는 동안뿐이고, 클라우드 파일은 읽을 때마다 받는다. +/// 복사해 두면 올리기에 실패해도 [다시 시도] · 서버가 찾지 못한 첨부(`MAIL_004`)를 다시 고르지 않고 올린다. +/// +/// 정리하는 때: 첨부를 뺄 때 · 쓰기 화면이 끝날 때(그 화면의 복사본) · 앱을 띄울 때(main — 지난 실행이 남긴 것) · +/// 메일을 보는 직원이 바뀔 때(모두) · 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다 — 그래서 올리기 전에 복사본이 있는지 본다). +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 고르기 한 번을 기다리는 가장 긴 시간 — 고르는 창을 연 때부터 고른 파일을 다 가져올 때까지(창에 머문 시간도 든다). +/// 파일당 20MB · 합계 25MB 를 느린 망에서 클라우드 파일로 받아도 넉넉하게 10분. 넘으면 그 고르기를 [PickOutcome.timedOut] 으로 끝내고 +/// 차례를 넘긴다 — 고른 앱(provider)이 읽기에서 멈춰 기기가 답하지 않을 때 [보내기] · [파일 첨부] · 다음 고르기 · 직원이 바뀔 때의 정리가 +/// 그 뒤에 묶이지 않게. 늦게 온 기기의 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 +const Duration kPickTimeout = Duration(minutes: 10); + +/// 고른 파일을 복사해 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `MainActivity.kt` 의 `UPLOADS_DIR`** — 다른 앱에 건네지 않으므로 FileProvider 경로(`downloaded_file_paths.xml`)에는 넣지 않는다 +const String kPickedFilesFolder = 'uploads'; + +/// 고른 파일 하나를 가져온 결과 (짝: `MainActivity.kt` 의 `STATUS_*` — 기기가 거른 차례: 개수 → 형식 → 파일당 → 합계) +enum PickedFileStatus { + /// 다 복사했다 — [PickedFile.path] 가 복사본 + ok, + + /// 넣을 파일이 `maxCount` 개가 되어 가져오지 않았다 + skipped, + + /// 허용 확장자(`extensions`)가 아니다 — 읽지 않았다 + notAllowed, + + /// 한도(`maxBytes`)보다 크다 — 복사하지 않았다(고른 앱이 알려 준 크기로, 또는 한도 + 1바이트를 읽고 멈췄다) + tooLarge, + + /// 넣으면 남은 합계(`maxTotalBytes`)를 넘는다 — 알려 준 크기로 읽지 않았거나, 복사한 크기로 보고 복사본을 지웠다 + overTotal, + + /// 읽지 못했다(클라우드 파일을 받지 못함 · 권한 · 저장 공간 · `content://` 가 아닌 주소) + failed, +} + +/// 고른 파일 하나 +@immutable +class PickedFile { + const PickedFile({ + required this.name, + required this.status, + this.size, + this.path, + }); + + /// 보이는 이름 — [displayFileName] 으로 정리했다(올릴 때 이 이름을 보낸다) + final String name; + final PickedFileStatus status; + + /// [PickedFileStatus.ok] 면 복사한 바이트, 그 밖은 고른 앱이 알려 준 크기(모르면 null) + final int? size; + + /// 복사본의 경로 — [PickedFileStatus.ok] 일 때만(로그 · 알림에 싣지 않는다) + final String? path; + + /// 채널이 돌려준 항목 하나 — 모양이 틀리면 읽지 못한 파일로 본다(알리기는 한다) + static PickedFile fromChannel(Object? raw) { + if (raw is! Map) { + return const PickedFile( + name: kFallbackFileName, + status: PickedFileStatus.failed, + ); + } + final Object? name = raw['name']; + final Object? size = raw['size']; + final Object? path = raw['path']; + final PickedFileStatus? status = switch (raw['status']) { + 'ok' => PickedFileStatus.ok, + 'skipped' => PickedFileStatus.skipped, + 'notAllowed' => PickedFileStatus.notAllowed, + 'tooLarge' => PickedFileStatus.tooLarge, + 'overTotal' => PickedFileStatus.overTotal, + 'failed' => PickedFileStatus.failed, + _ => null, + }; + final String shown = displayFileName(name is String ? name : null); + final int? bytes = size is int && size >= 0 ? size : null; + if (status == PickedFileStatus.ok) { + // 다 복사했다는데 경로 · 크기가 없으면 쓸 수 없다 + if (path is! String || path.isEmpty || bytes == null) { + return PickedFile(name: shown, status: PickedFileStatus.failed); + } + return PickedFile( + name: shown, + status: PickedFileStatus.ok, + size: bytes, + path: path, + ); + } + return PickedFile( + name: shown, + status: status ?? PickedFileStatus.failed, + size: bytes, + ); + } + + @override + bool operator ==(Object other) => + other is PickedFile && + other.name == name && + other.status == status && + other.size == size && + other.path == path; + + @override + int get hashCode => Object.hash(name, status, size, path); +} + +/// 고르기 한 번의 결과 (짝: `MainActivity.kt` 의 `OUTCOME_*`) +enum PickOutcome { + /// 골랐다 — [PickResult.files] + picked, + + /// 고르지 않고 닫았다 + canceled, + + /// 고르는 창이 이미 열려 있다 + busy, + + /// 고르는 창을 열 앱이 없다 + noApp, + + /// 그 밖의 실패(채널이 없음 — iOS 는 아직 없다 · 모르는 답) + failed, + + /// [kPickTimeout] 안에 기기가 답하지 않았다 — 고른 파일을 가져오지 못한 것으로 본다(앱에서만 — 기기는 보내지 않는다) + timedOut, +} + +/// 고르기 한 번의 결과 +@immutable +class PickResult { + const PickResult(this.outcome, [this.files = const []]); + + final PickOutcome outcome; + + /// 고른 차례대로 — [PickOutcome.picked] 일 때만 + final List files; + + /// 채널의 답 — 모양이 틀리면 실패 + static PickResult parse(Object? raw) { + if (raw is! Map) { + return const PickResult(PickOutcome.failed); + } + final PickOutcome? outcome = switch (raw['outcome']) { + 'picked' => PickOutcome.picked, + 'canceled' => PickOutcome.canceled, + 'busy' => PickOutcome.busy, + 'noApp' => PickOutcome.noApp, + 'failed' => PickOutcome.failed, + _ => null, + }; + if (outcome == null) return const PickResult(PickOutcome.failed); + if (outcome != PickOutcome.picked) return PickResult(outcome); + final Object? files = raw['files']; + if (files is! List) return const PickResult(PickOutcome.failed); + return PickResult( + PickOutcome.picked, + List.unmodifiable([ + for (final Object? item in files) PickedFile.fromChannel(item), + ]), + ); + } +} + +/// 고른 파일을 가져오고 복사본을 다루는 곳 — 시험은 덮어쓴다(시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다) +abstract class PickedFileStore { + /// 고르는 창을 열고 고른 파일 가운데 넣을 파일만 복사해 둔다 — 고른 차례대로, 쓰는 쪽 사전 검사와 같은 차례로 거른다: + /// 넣을 파일이 [maxCount] 개가 되면 나머지는 가져오지 않고([PickedFileStatus.skipped]), 확장자가 [extensions] 에 없으면 읽지 않고 + /// ([PickedFileStatus.notAllowed]), [maxBytes] + 1바이트를 넘으면 복사를 멈추고([PickedFileStatus.tooLarge]), + /// 넣은 파일의 합이 [maxTotalBytes] 를 넘게 되면 넣지 않는다([PickedFileStatus.overTotal]). 거른 파일은 개수 · 합계에 들지 않는다 + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }); + + /// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 복사본을 읽는다(올리기 — 부를 때마다 처음부터) + Stream> openRead(String path); + + /// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다 + Future delete(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다 +final Provider pickedFileStoreProvider = + Provider((Ref ref) => PlatformPickedFileStore()); + +/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder]. +/// +/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다 +/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도). +class PlatformPickedFileStore implements PickedFileStore { + PlatformPickedFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + MethodChannel channel = PlatformFileOpener.channel, + Duration pickTimeout = kPickTimeout, + }) : _baseDirectory = baseDirectory, + _channel = channel, + _pickTimeout = pickTimeout; + + /// 짝: `MainActivity.kt` 의 `PICK_METHOD` (채널은 받은 파일 열기와 같다 — `PlatformFileOpener.channel`) + static const String pickMethod = 'pick'; + + final Future Function() _baseDirectory; + final MethodChannel _channel; + + /// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게) + final Duration _pickTimeout; + Future _queue = Future.value(); + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null + Future _root() async { + try { + final Directory base = await _baseDirectory(); + return Directory('${base.path}/$kPickedFilesFolder'); + } on Exception { + return null; + } + } + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) => _serial(() async { + try { + // 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로) + final Object? raw = await _channel + .invokeMethod(pickMethod, { + 'maxBytes': maxBytes, + 'maxCount': maxCount, + 'maxTotalBytes': maxTotalBytes, + 'extensions': extensions, + }) + .timeout(_pickTimeout); + return PickResult.parse(raw); + } on TimeoutException { + // 기기가 답하지 않는다(고른 앱이 읽기에서 멈춤 등) — 이 고르기를 끝내고 차례를 넘긴다. + // 늦게 온 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 + return const PickResult(PickOutcome.timedOut); + } on PlatformException { + return const PickResult(PickOutcome.failed); + } on MissingPluginException { + return const PickResult(PickOutcome.failed); + } + }); + + /// 복사본의 폴더 — 복사본 폴더 바로 아래 한 칸(무작위 폴더)일 때만. 기호 링크를 풀어 견준다(Kotlin 이 canonical 로 만든 자리). + /// 없거나 밖이면 null + Future _copyFolderOf(String path) async { + final Directory? root = await _root(); + if (root == null) return null; + try { + final String rootPath = await root.resolveSymbolicLinks(); + final File file = File(await File(path).resolveSymbolicLinks()); + final Directory folder = file.parent; + return folder.parent.path == rootPath ? folder : null; + } on FileSystemException { + return null; + } + } + + @override + Future exists(String path) async { + if (await _copyFolderOf(path) == null) return false; + return File(path).exists(); + } + + @override + Stream> openRead(String path) => File(path).openRead(); + + @override + Future delete(String path) async { + final Directory? folder = await _copyFolderOf(path); + if (folder == null) return; + try { + await folder.delete(recursive: true); + } on FileSystemException { + // 이미 없다 — 다음 정리 · 기기가 지운다 + } + } + + @override + Future purge() => _serial(() async { + final Directory? root = await _root(); + if (root == null) return; + try { + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 다음 정리 · 기기가 지운다 + } + }); +} diff --git a/flutter/lib/core/utils/file_size.dart b/flutter/lib/core/utils/file_size.dart index 1652191..2ba80d3 100644 --- a/flutter/lib/core/utils/file_size.dart +++ b/flutter/lib/core/utils/file_size.dart @@ -1,6 +1,7 @@ -/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` 와 같은 글(순수 함수). +/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` · `formatMegabytes` 와 같은 글(순수 함수). /// /// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. +/// 한도 글은 "20MB"(붙여 씀 — [formatMegabytes]). library; const int _bytesPerUnit = 1024; @@ -24,6 +25,16 @@ String formatFileSize(int bytes) { return '$shown ${_units[unit]}'; } +/// 한도 글의 크기 — "20MB" · "25MB" · "20.5MB" (웹 `format.ts` 의 `formatMegabytes` 와 같은 글 — 정수 MB 면 그대로, 아니면 소수 한 자리). +/// 첨부 한도 안내 · 한도를 넘은 알림에 쓴다(서버가 알려 준 바이트 — `GET /api/mail-quota`) +String formatMegabytes(int bytes) { + final double megabytes = bytes / (_bytesPerUnit * _bytesPerUnit); + final String shown = megabytes == megabytes.roundToDouble() + ? megabytes.round().toString() + : _oneDecimal(megabytes); + return '${shown}MB'; +} + /// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다) String _oneDecimal(double value) { final double rounded = (value * 10).round() / 10; diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 072f886..41a7c68 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -1,5 +1,5 @@ /// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과 -/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다). +/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다. "은/는" 은 앱에서 더했다). library; /// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) @@ -64,6 +64,15 @@ String directionParticle(String word) { return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로'; } +/// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는). +/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다 +/// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙) +String topicParticle(String word) { + final int? jongseong = _lastJongseong(word); + if (jongseong == null) return '은(는)'; + return jongseong == _noJongseong ? '는' : '은'; +} + /// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; diff --git a/flutter/lib/features/mail/data/models/compose_models.dart b/flutter/lib/features/mail/data/models/compose_models.dart index 6b0cf63..aecf771 100644 --- a/flutter/lib/features/mail/data/models/compose_models.dart +++ b/flutter/lib/features/mail/data/models/compose_models.dart @@ -1,4 +1,4 @@ -/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings`, 4절 `LargeFile` · `MissingLargeFile`. +/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings` · `MailQuota`(첨부 한도), 4절 `LargeFile` · `MissingLargeFile`. /// /// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null — 저장소가 `SYS_001` 로 던진다. /// 나중 단계에 더해진 칸(`largeFiles` · `pendingSend` · `rejectedRecipients`)은 없으면 빈 목록 · null 로 읽는다(웹과 같다). @@ -331,3 +331,42 @@ int? undoSendSecondsOf(Object? json) { if (seconds is! num || seconds < 0) return null; return seconds.toInt(); } + +/// 첨부 한도 — `GET /api/mail-quota` 의 `attachmentMaxBytes` · `attachmentTotalMaxBytes` (계약 2절 `MailQuota` — 사용량 칸은 쓰지 않는다). +/// 받지 못하면 대체값(20 · 25MB — `kFallbackAttachmentLimits`)을 쓴다 +@immutable +class AttachmentLimits { + const AttachmentLimits({required this.maxBytes, required this.totalMaxBytes}); + + /// 파일 하나의 한도 — 새로 올리는 파일만 본다(전달하는 원본은 서버도 합계만 본다) + final int maxBytes; + + /// 메일 한 통의 첨부 합계 한도 — 전달하는 원본 · 저장본의 첨부까지 함께 센다 + final int totalMaxBytes; + + /// 모양이 틀리면(없음 · 0 이하) null + static AttachmentLimits? tryParse(Object? json) { + if (json is! Map) return null; + final Object? maxBytes = json['attachmentMaxBytes']; + final Object? totalMaxBytes = json['attachmentTotalMaxBytes']; + if (maxBytes is! num || + totalMaxBytes is! num || + maxBytes <= 0 || + totalMaxBytes <= 0) { + return null; + } + return AttachmentLimits( + maxBytes: maxBytes.toInt(), + totalMaxBytes: totalMaxBytes.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is AttachmentLimits && + other.maxBytes == maxBytes && + other.totalMaxBytes == totalMaxBytes; + + @override + int get hashCode => Object.hash(maxBytes, totalMaxBytes); +} diff --git a/flutter/lib/features/mail/data/repositories/compose_repository.dart b/flutter/lib/features/mail/data/repositories/compose_repository.dart index 7bba60d..ce478c7 100644 --- a/flutter/lib/features/mail/data/repositories/compose_repository.dart +++ b/flutter/lib/features/mail/data/repositories/compose_repository.dart @@ -7,6 +7,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; import 'package:dio/dio.dart'; @@ -33,6 +34,12 @@ abstract final class ComposeApiPaths { /// 대용량 파일 하나의 상태 — 발송이 `MAIL_016` 이면 파일마다 다시 묻는다(계약 4절) static String fileUpload(String id) => '/file-uploads/${Uri.encodeComponent(id)}'; + + /// 첨부 올리기 — multipart `file` 하나(계약 2절 `POST /api/mail-attachments`) + static const String mailAttachments = '/mail-attachments'; + + /// 메일 사용량 · 첨부 한도 + static const String mailQuota = '/mail-quota'; } /// 발송 · 저장의 받기 한도 — 서버가 55~56초 안에 답한다(계약 2절). 기본 한도(15초)로 끊으면 화면은 실패로 보는데 서버는 이미 보냈을 수 있다 @@ -42,6 +49,15 @@ const Duration kMailSendTimeout = Duration(seconds: 60); /// 중복 발송 방지 키 머리 (계약 2절) const String kIdempotencyHeader = 'Idempotency-Key'; +/// 첨부 올리기의 보내기 한도 — 본문 전체에 건다(Dio 5.9 는 sendTimeout 을 본문을 다 보낼 때까지 잰다). 20MB 를 느린 망에서도 +const Duration kAttachmentUploadSendTimeout = Duration(minutes: 5); + +/// 첨부 올리기의 받기 한도 — 다 보낸 뒤 서버가 메일 엔진에 넘기고 답한다(발송과 같은 60초) +const Duration kAttachmentUploadReceiveTimeout = Duration(seconds: 60); + +/// 첨부 올리기의 multipart 칸 이름 (백엔드 `FileInterceptor('file')`) +const String kAttachmentFileField = 'file'; + /// 처음 받아 두는 추천 수 — 계약의 최대 limit (웹 `RECIPIENT_SUGGESTION_PRELOAD`) const int kRecipientSuggestionPreload = 100; @@ -57,7 +73,7 @@ typedef RecipientSuggestionPage = ({ bool hasNext, }); -/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). +/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 첨부 올리기 · 첨부 한도 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). /// /// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. /// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. @@ -249,6 +265,58 @@ class ComposeRepository { return file; } + /// 첨부 하나를 올린다 — multipart `file`, 형식은 확장자의 정식 형식([type] — 서버가 확장자와 형식을 함께 본다), 이름은 고른 파일의 보이는 이름. + /// [read] 는 부를 때마다 처음부터 읽는다(401 뒤 갱신해 다시 보낼 때 — 인증 인터셉터가 FormData 를 복제한다). + /// 실패는 화면이 그 줄에 사전 문구로 보인다 — 알리지 않는다 + Future uploadAttachment({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }) async { + final FormData form = FormData() + ..files.add( + MapEntry( + kAttachmentFileField, + MultipartFile.fromStream( + read, + length, + filename: name, + contentType: DioMediaType.parse(type), + ), + ), + ); + final Response response = await _dio.post( + ComposeApiPaths.mailAttachments, + data: form, + options: Options( + sendTimeout: kAttachmentUploadSendTimeout, + receiveTimeout: kAttachmentUploadReceiveTimeout, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancelToken, + onSendProgress: onProgress, + ); + final MessageAttachment? attachment = MessageAttachment.tryParse( + response.data, + ); + if (attachment == null) throw _malformed(response); + return attachment; + } + + /// 첨부 한도(파일당 · 합계) — 알리지 않는다(받지 못하면 대체값으로 쓴다) + Future getAttachmentLimits() async { + final Response response = await _dio.get( + ComposeApiPaths.mailQuota, + options: ApiOptions.silent(), + ); + final AttachmentLimits? limits = AttachmentLimits.tryParse(response.data); + if (limits == null) throw _malformed(response); + return limits; + } + Options _longOptions({required bool silent}) => Options( receiveTimeout: kMailSendTimeout, extra: {if (silent) kSilentExtraKey: true}, diff --git a/flutter/lib/features/mail/domain/compose_attachments.dart b/flutter/lib/features/mail/domain/compose_attachments.dart new file mode 100644 index 0000000..8e5d830 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_attachments.dart @@ -0,0 +1,136 @@ +/// 첨부의 규칙 — 형식 표 · 한도 · 사전 검사 (순수 함수, 계약 2절 "첨부 업로드 한도"). +/// +/// 짝: 백엔드 `modules/mail/attachment.policy.ts` 의 `ATTACHMENT_TYPES`(확장자 → 정식 형식 — 시험이 그 파일과 맞춰 본다) · +/// `MAX_ATTACHMENTS_PER_MESSAGE`, 웹 `shared/utils/mail-attachment.ts`(`precheckAttachment` — 개수 → 형식 → 파일당 → 합계 차례) · +/// `shared/constants/mail.ts`(대체 한도). 한쪽을 고치면 둘 다. +library; + +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; + +/// 메일 한 통의 첨부 개수 한도 — 올린 파일과 전달하는 원본을 함께 센다 (백엔드 `MAX_ATTACHMENTS_PER_MESSAGE` · 웹 `MAIL_MAX_ATTACHMENTS`) +const int kMailMaxAttachments = 20; + +/// 파일당 크기 대체값 — 서버 기본값과 같다(`GET /api/mail-quota` 를 받지 못했을 때 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`) +const int kMailAttachmentMaxBytes = 20 * 1024 * 1024; + +/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`) +const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024; + +/// 한도를 받지 못했을 때 쓰는 값 +const AttachmentLimits kFallbackAttachmentLimits = AttachmentLimits( + maxBytes: kMailAttachmentMaxBytes, + totalMaxBytes: kMailAttachmentTotalMaxBytes, +); + +/// 허용 형식 — 확장자(소문자, 점 없이) → 올릴 때 보내는 정식 형식. +/// +/// 서버는 올린 파일의 확장자와 형식을 **둘 다** 본다(multer `isAllowedAttachment` — 그 확장자가 받는 형식이어야 한다). +/// 안드로이드가 알려 주는 형식은 기기 · 앱마다 달라(hwp 가 octet-stream 이거나 한컴 형식) 쓰지 않고, 확장자의 정식 형식을 보낸다. +/// **짝: 백엔드 `ATTACHMENT_TYPES` 의 `type`** (`compose_attachments_test.dart` 가 그 파일을 읽어 맞춰 본다) +const Map kMailAttachmentTypes = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'hwp': 'application/x-hwp', + 'hwpx': 'application/hwp+zip', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'zip': 'application/zip', +}; + +/// 확장자 — 소문자, 없으면 빈 글 (웹 `extensionOf` 와 같다 — 마지막 점 뒤). +/// **짝: `MainActivity.kt` 의 `extensionOf`** — 고를 때 기기가 같은 규칙으로 형식을 미리 거른다(보이는 이름 `displayFileName` 도 짝) +String extensionOf(String name) { + final int dot = name.lastIndexOf('.'); + return dot == -1 ? '' : name.substring(dot + 1).toLowerCase(); +} + +/// 올릴 때 보내는 형식 — 허용 목록에 없으면(HEIC 등) null +String? attachmentUploadTypeOf(String name) => + kMailAttachmentTypes[extensionOf(name)]; + +/// 그림 첨부인가 — 줄 아이콘(보드: 그림은 그림 아이콘, 그 밖은 문서 아이콘) +bool isImageAttachment(String name) => + attachmentUploadTypeOf(name)?.startsWith('image/') ?? false; + +/// 이미 붙은 첨부(전달하는 원본 · 저장본의 첨부)를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다). +/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null +String? precheckCarriedAttachment({ + required String name, + required int size, + required int attachedCount, + required int attachedBytes, + int totalMaxBytes = kMailAttachmentTotalMaxBytes, +}) { + if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006'; + if (attachmentUploadTypeOf(name) == null) return 'FILE_001'; + if (attachedBytes + size > totalMaxBytes) return 'FILE_002'; + return null; +} + +/// 고른 파일을 넣지 못한 까닭 +enum AttachmentRejection { + /// 개수 한도(`MAIL_006`) — 복사본 수 한도로 가져오지 않은 파일도 + tooMany, + + /// 허용하지 않는 형식(`FILE_001`) — HEIC 도 + notAllowed, + + /// 파일당 한도보다 크다 — 앱은 아직 대용량 첨부(링크)로 보내지 못한다 + tooLarge, + + /// 고른 파일을 읽지 못했다 + unreadable, + + /// 합계 한도를 넘게 만든다 + overTotal, +} + +/// 고른 파일을 붙이기 전 사전 검사 — 걸리면 붙이지 않는다. 웹 `precheckAttachment` 와 같은 차례: +/// 1) 개수(서버도 형식보다 먼저 본다 — 허용 밖 형식이어도 한도를 넘으면 개수) 2) 형식 3) 파일당 크기 4) (앱에서 더함) 읽지 못함 5) 합계 크기. +/// [attachedCount] · [attachedBytes] 는 이미 붙은 것(올리는 중 · 올림 · 전달 원본) — 앞에서 넣은 파일이 늘린다. +/// +/// 기기(`MainActivity.kt` 의 `copyPicked`)가 같은 차례로 미리 걸러 넘긴 까닭(`skipped` · `notAllowed` · `tooLarge` · `overTotal`)은 +/// 그 차례의 까닭으로 옮기고, 다 복사한 파일(`ok`)은 여기서 다시 판정한다(기기는 미리 거르기만 한다) +AttachmentRejection? precheckPickedFile( + PickedFile file, { + required int attachedCount, + required int attachedBytes, + required AttachmentLimits limits, +}) { + if (file.status == PickedFileStatus.skipped || + attachedCount + 1 > kMailMaxAttachments) { + return AttachmentRejection.tooMany; + } + if (file.status == PickedFileStatus.notAllowed || + attachmentUploadTypeOf(file.name) == null) { + return AttachmentRejection.notAllowed; + } + final int? size = file.size; + if (file.status == PickedFileStatus.tooLarge || + (size != null && size > limits.maxBytes)) { + return AttachmentRejection.tooLarge; + } + if (file.status == PickedFileStatus.overTotal) { + return AttachmentRejection.overTotal; + } + if (file.status != PickedFileStatus.ok || size == null) { + return AttachmentRejection.unreadable; + } + if (attachedBytes + size > limits.totalMaxBytes) { + return AttachmentRejection.overTotal; + } + return null; +} diff --git a/flutter/lib/features/mail/domain/compose_send.dart b/flutter/lib/features/mail/domain/compose_send.dart index e8abb96..9209b01 100644 --- a/flutter/lib/features/mail/domain/compose_send.dart +++ b/flutter/lib/features/mail/domain/compose_send.dart @@ -1,7 +1,8 @@ -/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 첨부 사전 검사 (순수 함수). +/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 대용량 파일 덩이 몫 (순수 함수). +/// 첨부의 형식 · 한도 · 사전 검사는 `compose_attachments.dart`. /// /// 짝: 웹 `shared/utils/idempotency-key.ts`(`shouldRenewSendKey`) · `uuid.ts` · `mail-send-result.ts`(`partialSendMessage`) · -/// `pending-send.ts`(`undoToastDeadline`) · `mail-attachment.ts`(`precheckAttachment`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). +/// `pending-send.ts`(`undoToastDeadline`) · `large-file.ts`(`largeFileBlockTextReserve`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). library; import 'dart:math' as math; @@ -99,44 +100,6 @@ Duration? undoSendRemaining({ return remaining > kUndoSendMax ? kUndoSendMax : remaining; } -// ── 첨부 사전 검사 (계약 2절 "첨부 업로드 한도" — 전달하는 원본 · 저장본의 첨부) ── - -/// 메일 한 통의 첨부 개수 한도 (웹 `MAIL_MAX_ATTACHMENTS`) -const int kMailMaxAttachments = 20; - -/// 파일당 크기 대체값 — 서버 기본값과 같다(앱은 첫 판에서 올리지 않아 안내에만 쓴다 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`) -const int kMailAttachmentMaxBytes = 20 * 1024 * 1024; - -/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`) -const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024; - -/// 허용 형식(확장자) — 계약 2절 (웹 `MAIL_ATTACHMENT_EXTENSIONS`) -const Set kMailAttachmentExtensions = { - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'txt', // - 'csv', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'zip', -}; - -/// 확장자 — 소문자, 없으면 빈 글 -String extensionOf(String name) { - final int dot = name.lastIndexOf('.'); - return dot == -1 ? '' : name.substring(dot + 1).toLowerCase(); -} - -/// 이미 붙은 첨부를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다). -/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null -String? precheckCarriedAttachment({ - required String name, - required int size, - required int attachedCount, - required int attachedBytes, - int totalMaxBytes = kMailAttachmentTotalMaxBytes, -}) { - if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006'; - if (!kMailAttachmentExtensions.contains(extensionOf(name))) return 'FILE_001'; - if (attachedBytes + size > totalMaxBytes) return 'FILE_002'; - return null; -} - /// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 텍스트 한도에서 남기는 몫 — 파일마다 이름 + 24, 머리 600 /// (웹 `largeFileBlockTextReserve` · `LARGE_FILE_BLOCK_TEXT_PER_FILE` · `_BASE`) int largeFileBlockTextReserve(Iterable names) { diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index e0486bd..3506270 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -29,9 +29,11 @@ abstract final class MailComposeTexts { /// 보내는 동안의 [보내기] — 화면 낭독기 (앱에서 새로 씀) static const String sending = '보내는 중입니다.'; - /// 앱은 첫 판에서 파일을 올리지 않는다 — [파일 첨부]를 누르면 (앱에서 새로 씀 · 메일 찾기의 "아직 준비 중"과 같은 결) - static const String attachNotReady = - '앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요.'; + /// 고르는 창을 열었거나 고른 파일을 가져오는 동안 위의 진행 막대 — 화면 낭독기 (앱에서 새로 씀) + static const String picking = '파일을 가져오는 중'; + + /// 고르는 창을 열지 못했다(열 앱이 없음 · 이 기기에서 아직 고를 수 없음) (앱에서 새로 씀) + static const String pickFailed = '파일을 고르지 못했습니다. 다시 시도해 주세요.'; // ── 칸 ── static const String to = '받는 사람'; @@ -55,6 +57,54 @@ abstract final class MailComposeTexts { // ── 첨부 ── static String attachmentsTitle(int count) => '첨부 ${formatCount(count)}개'; + /// 첨부 머리 오른쪽 — "파일당 최대 20MB · 합계 25MB" (보드 — 한도는 서버 값 `GET /api/mail-quota`) + static String attachmentLimits(String perFile, String total) => + '파일당 최대 $perFile · 합계 $total'; + + /// 올리는 줄 — "올리는 중 40%" (보드) + static String uploadProgress(int percent) => '올리는 중 $percent%'; + + /// 올리는 줄의 진행 막대 — 화면 낭독기 "현장사진_1104.jpg 올리는 중" (보드) + static String uploading(String name) => '$name 올리는 중'; + + /// 올리는 줄의 ✕ — 화면 낭독기 "‘현장사진_1104.jpg’ 올리기 취소" (보드 · 계획) + static String cancelUpload(String name) => '‘$name’ 올리기 취소'; + + /// 올리지 못한 줄 — 그 아래에 사전 문구 (계획) + static const String uploadFailed = '올리지 못했습니다.'; + + /// 올리지 못한 줄의 [다시 시도] — 고른 파일의 복사본으로 다시 올린다 + static const String retryUpload = '다시 시도'; + + /// [다시 시도] — 화면 낭독기 "‘현장사진_1104.jpg’ 다시 올리기" (앱에서 새로 씀) + static String retryUploadLabel(String name) => '‘$name’ 다시 올리기'; + + /// 올리려던 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 올릴 수 없다 (앱에서 새로 씀) + static const String uploadCopyGone = '올리려던 파일을 찾지 못했습니다. 빼고 다시 골라 주세요.'; + + /// 파일당 한도보다 큰 파일 — "20MB보다 큰 …" (계획 — 한도는 서버 값) + static String fileTooLarge(String limit) => + '$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.'; + + /// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획). + /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)" + static String overTotal(String total, String name, {int others = 0}) { + final String target = others > 0 + ? '‘$name’ 외 ${formatCount(others)}개' + : '‘$name’'; + final String particle = topicParticle(others > 0 ? '개' : name); + return '첨부를 모두 합치면 $total를 넘어 $target$particle 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.'; + } + + /// 고른 파일을 읽지 못했다(클라우드 파일을 받지 못함 등) · 가져오다 시한(10분 — `kPickTimeout`)을 넘겼다 (앱에서 새로 씀) + static const String pickReadFailed = '파일을 가져오지 못했습니다. 다시 골라 주세요.'; + + /// 올리는 동안 [보내기] (계획) + static const String attachmentsUploading = '올리는 중인 첨부가 있습니다. 다 올린 뒤 보내 주세요.'; + + /// 고른 파일을 가져오는 동안 [보내기] (앱에서 새로 씀 — 리뷰에서 정함) + static const String attachmentsPicking = '첨부할 파일을 가져오는 중입니다. 다 가져온 뒤 보내 주세요.'; + /// 함께 보낼 수 없는 첨부(형식 · 개수 · 합계) (앱에서 새로 씀) static const String unsendable = '이 첨부는 함께 보낼 수 없습니다. 빼고 보내 주세요.'; @@ -106,6 +156,10 @@ abstract final class MailComposeTexts { static const String leaveMessage = '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.'; static const String stay = '계속 쓰기'; static const String leave = '나가기'; + + // 올리는 중 나가기 확인 (계획 — 버튼 · 초점은 위 나가기 확인과 같다) + static const String uploadLeaveTitle = '올리는 중인 첨부가 있습니다'; + static const String uploadLeaveMessage = '이 화면을 나가면 올리던 파일은 빠집니다.'; } /// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은 diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index 157030d..eaba777 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -34,7 +34,8 @@ const double _bodyLineHeight = 1.7; /// 메일 쓰기 — 앱 보드 `App1Compose` (앱 디자인 README 「쓰기」). 탭 막대 없는 전체 화면이다. 규칙은 [ComposeController] 머리말. /// -/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](첫 판은 준비 중 알림) · [보내기]. +/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](고르는 창 — 고른 파일을 가져오는 동안 +/// 위에 진행 막대) · [보내기]. /// - 칸: 받는 사람(오른쪽에 참조 · 숨은참조 펼치기) · 참조 · 숨은참조 · 제목 · (서식이 있는 남의 저장본 안내) · 첨부 · 본문 · 서명 · 원본 메일(접힘 — 누르면 펼친다). /// 열면 비어 있는 첫 칸(받는 사람 → 제목 → 본문)에 초점을 둔다. /// - 보내기 전 검사 · 서버의 칸 안 실패(받는 사람 · 제목 · 본문 길이)는 그 칸 아래 띠로 알리고 그 칸으로 초점을 옮긴다. @@ -43,6 +44,8 @@ const double _bodyLineHeight = 1.7; /// - 나가기(✕ · 뒤로 · 몸짓): 잃을 것이 없으면 그대로 닫는다(예측 뒤로 가기가 산다 — 이 화면이 저장한 적이 있으면 "임시보관함에 저장했습니다."). /// 있으면 쓰다 만 주소를 칩으로 만들고 저장(5초까지)한 뒤 닫고 같은 알림을, 저장하지 못했으면 묻는다 — [계속 쓰기]가 처음 초점이자 주 단추이고 /// 묻는 사이 저장이 끝나면 그대로 닫는다. 보내는 중에는 묻지 않고 떠나게 둔다(결과는 알림으로). +/// 올리는 중인 첨부가 있으면 먼저 따로 묻는다("올리는 중인 첨부가 있습니다") — [나가기]면 올리던 파일을 빼고 위 차례대로, +/// 묻는 사이 모두 다 올렸으면 그대로 떠난다(올리지 못한 파일이 생겼으면 답을 기다린다). /// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다. class MessageComposePage extends ConsumerStatefulWidget { const MessageComposePage({required this.intent, this.prefill, super.key}); @@ -95,6 +98,12 @@ class _MessageComposePageState extends ConsumerState { /// 창을 열 때의 저장 차례 — 이보다 오르면 그사이 저장이 끝난 것이다 int _askedSaveCount = 0; + /// 올리는 중 나가기 확인 창이 열려 있다 — 그사이 모두 다 올렸으면 창을 닫고 떠난다 + bool _askingUpload = false; + + /// 창을 열 때 올리지 못한 고른 파일의 수 — 묻는 사이 늘었으면(마지막 올리기가 실패) 저절로 떠나지 않고 답을 기다린다 + int _askedUploadFailures = 0; + /// 펼친 원본 메일의 줄 — 인용이 바뀔 때만 다시 만든다 ComposeQuote? _linesQuote; List _lines = const []; @@ -127,6 +136,21 @@ class _MessageComposePageState extends ConsumerState { next.status == ComposeStatus.ready) { _scheduleAutofocus(); } + // 올리는 중 나가기 확인 창이 떠 있다 — 세션이 끝나면 창만 닫고, 모두 다 올렸으면 창을 닫고 떠난다. + // 묻는 사이 올리지 못한 파일이 생겼으면 닫지 않고 답을 기다린다(그 파일 없이 저장하고 떠나며 알리지 않는 일이 없게) + if (_askingUpload && + mounted && + !(ModalRoute.of(context)?.isCurrent ?? true)) { + if (next.ended) { + _askingUpload = false; + Navigator.of(context, rootNavigator: true).pop(false); + } else if (!next.uploading && + failedUploadsOf(next) <= _askedUploadFailures) { + _askingUpload = false; + Navigator.of(context, rootNavigator: true).pop(true); + } + return; + } // 나가기 확인 창이 떠 있다(창이 이미 닫히는 중이면 건드리지 않는다) if (!_askingLeave || !mounted || @@ -178,7 +202,18 @@ class _MessageComposePageState extends ConsumerState { /// ✕ · 막힌 뒤로 가기 — 위 머리말의 차례. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다 Future _leave() async { if (_leaving || _closing || !mounted) return; - final ComposeState state = ref.read(composeProvider(_args)); + ComposeState state = ref.read(composeProvider(_args)); + if (state.uploading && !state.sending && !state.ended) { + // 올리는 중인 첨부가 있다 — 먼저 따로 묻는다([나가기]면 올리던 파일을 빼고 아래 차례대로) + setState(() => _leaving = true); + final bool leave = await _askLeaveUploading(); + if (!mounted) return; + setState(() => _leaving = false); + if (!leave) return; + _controller.dropUploadingAttachments(); + state = ref.read(composeProvider(_args)); + if (state.ended) return; + } if (!state.unsaved || state.sending) { _close(announceSaved: _controller.savedForLeave); return; @@ -219,6 +254,22 @@ class _MessageComposePageState extends ConsumerState { } } + /// 올리는 중 나가기 확인 — [계속 쓰기]가 처음 초점이자 주 단추. 묻는 동안 모두 다 올리면 저절로 [나가기]로 닫힌다([_onStateChanged]) + Future _askLeaveUploading() async { + _askingUpload = true; + _askedUploadFailures = failedUploadsOf(ref.read(composeProvider(_args))); + final bool leave = await showConfirmDialog( + context, + title: MailComposeTexts.uploadLeaveTitle, + message: MailComposeTexts.uploadLeaveMessage, + confirmLabel: MailComposeTexts.leave, + cancelLabel: MailComposeTexts.stay, + stayIsPrimary: true, + ); + _askingUpload = false; + return leave; + } + /// 닫는다 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 메일 첫 화면으로 void _close({required bool announceSaved}) { if (_closing || !mounted) return; @@ -318,8 +369,8 @@ class _MessageComposePageState extends ConsumerState { appBar: ComposeAppBar( title: composeTitleOf(state), onClose: () => unawaited(_leave()), - onAttach: editable - ? () => _notice(MailComposeTexts.attachNotReady) + onAttach: editable && !state.picking && !_leaving + ? () => unawaited(_controller.pickAttachments()) : null, onSend: ready && !_leaving ? () => unawaited(_send()) : null, sending: state.sending, @@ -335,7 +386,23 @@ class _MessageComposePageState extends ConsumerState { onRetry: () => unawaited(_controller.retry()), onLeave: () => unawaited(_leave()), ), - ComposeStatus.ready => _form(context, state), + ComposeStatus.ready => Stack( + fit: StackFit.expand, + children: [ + _form(context, state), + // 고르는 창을 열었거나 고른 파일을 가져오는 동안 — 위에 진행 막대(칸을 밀지 않게 겹친다) + if (state.picking) + const Positioned( + top: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + minHeight: AppSize.progressBarHeight, + semanticsLabel: MailComposeTexts.picking, + ), + ), + ], + ), }, ), ); @@ -394,7 +461,9 @@ class _MessageComposePageState extends ConsumerState { ComposeAttachmentsSection( attachments: state.attachments, largeFiles: state.largeFiles, + limits: state.limits, onRemoveAttachment: controller.removeAttachment, + onRetryAttachment: controller.retryAttachment, onRemoveLargeFile: controller.removeLargeFile, enabled: enabled, ), @@ -470,6 +539,13 @@ class _MessageComposePageState extends ConsumerState { } } +/// 올리지 못한 고른 파일의 수 +int failedUploadsOf(ComposeState state) => state.attachments + .where( + (ComposeAttachment item) => item.upload == ComposeUploadStatus.failed, + ) + .length; + /// 위 막대의 제목 — 쓰기 종류(저장본을 받기 전에는 "메일 쓰기") String composeTitleOf(ComposeState state) { if (!state.titleKnown) return MailComposeTexts.loadingTitle; diff --git a/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart b/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart new file mode 100644 index 0000000..3376515 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/attachment_uploads.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:dio/dio.dart'; + +/// 한 번에 올리는 첨부 수 — 나머지는 고른 차례대로 기다린다(웹은 고른 만큼 한꺼번에 — 휴대폰 망에서는 둘씩) +const int kAttachmentUploadConcurrency = 2; + +/// 첨부 하나 올리기 — 저장소의 `uploadAttachment`(시험은 가짜 서버 · 가짜 함수) +typedef AttachmentUploader = + Future Function({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }); + +/// 올릴 파일 하나 — 복사본과 보이는 이름 · 크기 +class _UploadJob { + const _UploadJob({ + required this.path, + required this.name, + required this.size, + }); + + final String path; + final String name; + final int size; +} + +/// 쓰기 화면에서 고른 파일 올리기 — 계약 2절 `POST /api/mail-attachments` (웹 `useAttachmentUploads` 와 같은 결). +/// +/// - 고른 차례대로 [kAttachmentUploadConcurrency] 개씩 올린다. 줄의 상태는 쓰기 화면이 소식([onStarted] · [onProgress] · [onDone] · +/// [onFailed])으로 바꾼다 — 이 줄은 차례 · 복사본 · 취소 표만 쥔다. +/// - 복사본(`PickedFileStore`)이 있는 동안은 다시 올릴 수 있다([requeue] — [다시 시도] · 서버가 찾지 못한 첨부 `MAIL_004`). +/// 올리기 전마다 복사본이 아직 있는지 본다(기기가 임시 폴더를 비웠으면 다시 골라야 한다). +/// - 뺀 파일([remove])은 올리던 것을 끊고 복사본을 지운다 — 그 뒤 소식은 오지 않는다. [close] 는 모두 그만두고 복사본을 모두 지운다. +class AttachmentUploadQueue { + AttachmentUploadQueue({ + required AttachmentUploader upload, + required PickedFileStore files, + required this.onStarted, + required this.onProgress, + required this.onDone, + required this.onFailed, + }) : _upload = upload, + _files = files; + + final AttachmentUploader _upload; + final PickedFileStore _files; + + /// 차례가 와 올리기 시작했다 + final void Function(int key) onStarted; + + /// 보낸 비율(0 ~ 100) — 보이는 비율이 바뀔 때만 + final void Function(int key, int percent) onProgress; + + /// 다 올렸다 — 서버의 첨부 + final void Function(int key, MessageAttachment attachment) onDone; + + /// 올리지 못했다 + final void Function(int key, ComposeUploadFailure failure) onFailed; + + /// 복사본이 있는 파일 — 화면의 줄 이름(key)마다 + final Map _jobs = {}; + + /// 차례를 기다리는 파일 — 고른 차례대로 + final List _waiting = []; + + /// 올리는 중인 파일과 그 취소 표 + final Map _running = {}; + + /// 마지막으로 알린 비율 — 같은 비율을 거듭 알리지 않는다 + final Map _percents = {}; + bool _closed = false; + + /// 복사본이 있다 — 다시 올릴 수 있다 + bool hasCopy(int key) => _jobs.containsKey(key); + + /// 올리는 중이거나 기다리는 파일이 있다 + bool get busy => _waiting.isNotEmpty || _running.isNotEmpty; + + /// 고른 파일 하나를 차례에 넣는다 — 그만둔 뒤면 복사본만 지운다 + void add( + int key, { + required String path, + required String name, + required int size, + }) { + if (_closed) { + unawaited(_files.delete(path)); + return; + } + _jobs[key] = _UploadJob(path: path, name: name, size: size); + _waiting.add(key); + _pump(); + } + + /// 복사본으로 다시 올린다([다시 시도] · `MAIL_004` 뒤) — 차례의 끝에 넣는다. + /// 복사본이 없거나 이미 올리는 중 · 기다리는 중이면 거짓 + bool requeue(int key) { + if (_closed || + !_jobs.containsKey(key) || + _waiting.contains(key) || + _running.containsKey(key)) { + return false; + } + _waiting.add(key); + _pump(); + return true; + } + + /// 뺀다 — 올리던 것을 끊고 복사본을 지운다(그 파일의 소식은 더 오지 않는다) + void remove(int key) => removeAll([key]); + + /// 여럿을 뺀다 — 다 뺀 뒤에 다음 차례를 시작한다(뺄 파일이 그사이 시작하지 않게) + void removeAll(Iterable keys) { + for (final int key in keys) { + _waiting.remove(key); + _running.remove(key)?.cancel(); + _percents.remove(key); + final _UploadJob? job = _jobs.remove(key); + if (job != null) unawaited(_files.delete(job.path)); + } + _pump(); + } + + /// 모두 그만둔다 — 올리던 것을 끊고 복사본을 모두 지운다(쓰기 화면이 끝날 때 · 세션이 끝날 때). 그 뒤의 [add] 는 복사본만 지운다 + void close() { + if (_closed) return; + _closed = true; + _waiting.clear(); + for (final CancelToken token in _running.values) { + token.cancel(); + } + _running.clear(); + _percents.clear(); + for (final _UploadJob job in _jobs.values) { + unawaited(_files.delete(job.path)); + } + _jobs.clear(); + } + + void _pump() { + while (!_closed && + _running.length < kAttachmentUploadConcurrency && + _waiting.isNotEmpty) { + final int key = _waiting.removeAt(0); + final _UploadJob? job = _jobs[key]; + if (job == null) continue; + final CancelToken cancel = CancelToken(); + _running[key] = cancel; + _percents[key] = 0; + onStarted(key); + unawaited(_run(key, job, cancel)); + } + } + + /// 그 올리기가 아직 이 파일의 올리기인가 — 뺐거나 그만뒀으면 거짓(그 소식은 버린다) + bool _owns(int key, CancelToken cancel) => + !_closed && identical(_running[key], cancel); + + Future _run(int key, _UploadJob job, CancelToken cancel) async { + MessageAttachment? uploaded; + ComposeUploadFailure? failure; + final String? type = attachmentUploadTypeOf(job.name); + if (type == null) { + // 사전 검사를 지났으면 오지 않는다 — 서버도 같은 까닭으로 거절한다 + failure = ComposeUploadFailure.of('FILE_001'); + } else if (!await _files.exists(job.path)) { + failure = ComposeUploadFailure.copyGone; + } else if (_owns(key, cancel)) { + try { + uploaded = await _upload( + name: job.name, + type: type, + length: job.size, + read: () => _files.openRead(job.path), + cancelToken: cancel, + onProgress: (int sent, int total) { + if (!_owns(key, cancel) || total <= 0) return; + final int percent = (sent * 100 ~/ total).clamp(0, 100); + if (_percents[key] == percent) return; + _percents[key] = percent; + onProgress(key, percent); + }, + ); + } on DioException catch (error) { + failure = error.type == DioExceptionType.cancel + ? null + : ComposeUploadFailure.of(apiErrorCodeOf(error) ?? kFallbackCode); + } on Object { + // 그 밖의 실패(뜻밖의 예외)도 줄을 "올리는 중"에 두지 않는다 — [다시 시도] + failure = ComposeUploadFailure.of(kFallbackCode); + } + } + if (!_owns(key, cancel)) return; + _running.remove(key); + _percents.remove(key); + final MessageAttachment? done = uploaded; + if (done != null) { + onDone(key, done); + } else if (failure != null) { + onFailed(key, failure); + } + _pump(); + } +} diff --git a/flutter/lib/features/mail/presentation/providers/compose_controller.dart b/flutter/lib/features/mail/presentation/providers/compose_controller.dart index ef0d4f1..8bb753c 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_controller.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/core/people/people_texts.dart'; import 'package:arasystem_app/core/people/person_address.dart'; @@ -12,6 +14,7 @@ import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; import 'package:arasystem_app/core/storage/token_store_provider.dart'; import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; @@ -20,6 +23,7 @@ import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; @@ -29,6 +33,7 @@ import 'package:arasystem_app/features/mail/domain/compose_send.dart'; import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/attachment_uploads.dart'; import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; @@ -73,6 +78,20 @@ final Provider draftSuccessorsProvider = return DraftSuccessors(); }); +/// 고른 파일의 복사본을 두는 곳 — 메일을 보는 직원이 바뀌면(로그아웃 · 세션 끝 · 다른 직원) 모두 지운다(받은 파일과 같은 결 — `mailDownloadedFilesProvider`). +/// 앱이 살아 있는 동안 둔다. 지난 실행이 남긴 복사본은 main 이 앱을 띄울 때 지운다(`PickedFileStore.purge`) +final Provider composePickedFilesProvider = + Provider((Ref ref) { + final PickedFileStore store = ref.watch(pickedFileStoreProvider); + ref.listen(mailAccountProvider, ( + String? previous, + String? next, + ) { + if (previous != next) unawaited(store.purge()); + }); + return store; + }); + /// 옛 저장본 id → 새 id (웹 `mail.store` 의 `recordDraftSuccessor` · `latestDraftId`) class DraftSuccessors { final Map _next = {}; @@ -120,6 +139,10 @@ const Set kComposeInlineCodes = { 'MAIL_016', }; +/// 전달 원본 · 저장본의 첨부를 검사하기 전에 첨부 한도를 기다리는 가장 긴 시간 — 넘으면 대체값(20 · 25MB)으로 잰다 +/// (웹은 기다리지 않고 받아 둔 값을 쓴다 — 앱은 열 때 한 번 받으므로 잠깐 기다린다. 한도 응답이 늦어도 화면이 그만큼 늦게 열리지 않게) +const Duration kAttachmentLimitsWait = Duration(seconds: 2); + /// 서버에 다시 묻기 전에 기다리는 시간 (웹 `RECIPIENT_SEARCH_DELAY_MS`) const Duration kRecipientSearchDelay = Duration(milliseconds: 250); @@ -189,6 +212,10 @@ class _FormSnapshot { /// 원본 · 저장본을 받지 못하면 [다시 시도] 화면이다. 처음 채운 내용은 "바꿈"이 아니다. /// - 본문은 글만(서식 편집은 다음 판) — 보낼 때 서식 본문 + 텍스트를 함께 만든다(`compose_body.dart`). 원문 인용은 글로 만든 덩이이고 /// 처음에는 한도의 95%, 보내기 · 저장 직전에 100% 로 맞춘다(`compose_fit.dart`). +/// - 첨부: [파일 첨부]는 고르는 창(`PickedFileStore`)으로 고른 파일을 복사해 사전 검사(개수 → 형식 → 파일당 → 합계 — 한도는 `GET /api/mail-quota`, +/// 받지 못하면 20 · 25MB)하고 [AttachmentUploadQueue] 로 둘씩 올린다. 저장 · 보내기에는 다 올린 첨부만 싣고, 올리는 동안은 보내기를 막는다. +/// 서버가 첨부를 찾지 못하면(`MAIL_004`) 고른 파일은 복사본으로 다시 올리고 원본 · 저장본의 첨부는 "찾지 못함"으로 둔다. +/// 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 지운다. /// - 임시 저장은 [DraftAutosave] — 쓰는 동안은 보이지 않고, 저장한 채 나가면 화면이 "임시보관함에 저장했습니다."를 알린다. /// - 보내기: 칸에 쓰다 만 주소 확정 → 사전 검사 → 진행 중인 저장 기다리기 → 길이 다시 재기 → POST(같은 키 — 키를 바꾸는 것은 성공과 `MAIL_005` 뿐). /// 보내는 동안 화면을 떠나도 응답까지 산다 — 결과는 알림으로만, 실패하면 같은 세션일 때만 쓰던 내용을 저장본으로 남긴다. @@ -220,6 +247,20 @@ class ComposeController extends Notifier { /// 웹 · 다른 프로그램이 만든 저장본 — 본문을 고치지 않으면 원래 HTML 그대로 보낸다 ForeignDraftBody? _foreign; int _nextAttachmentKey = 0; + + /// 고른 파일의 복사본 · 올리기 차례 + late final PickedFileStore _files; + late final AttachmentUploadQueue _uploads; + + /// 첨부 한도를 받는 일 — 전달 원본 · 저장본의 첨부를 검사하기 전에 잠깐 기다린다(받지 못하거나 늦으면 대체값) + Future _limitsLoaded = Future.value(); + + /// `MAIL_004` 로 저절로 다시 올린 줄 — 그것을 실은 저장이 성공하기 전에 또 `MAIL_004` 면 다시 올리지 않는다(되풀이를 막는다) + final Set _reuploaded = {}; + + /// 서버가 잃은(`MAIL_004`) 고른 파일 — 다시 올리기에 성공하거나 뺄 때까지, 올리지 못하면 "찾지 못함"으로 보내기 · 저장을 막는다. + /// 이미 이 메일에 실렸던 첨부라 모르는 사이 빠진 채 나가지 않게(웹 `markMissing` — 웹은 다시 올리지 않고 빼기만) + final Set _lostOnServer = {}; int? _undoSendSeconds; bool _savedOnce = false; @@ -262,6 +303,16 @@ class ComposeController extends Notifier { _account = ref.read(mailAccountProvider); _session = _tokens.sessionGeneration; _successors = ref.read(draftSuccessorsProvider); + _files = ref.read(composePickedFilesProvider); + _uploads = AttachmentUploadQueue( + upload: _repository.uploadAttachment, + files: _files, + onStarted: (int key) => _setUpload(key, ComposeUploadStatus.uploading), + onProgress: (int key, int percent) => + _setUpload(key, ComposeUploadStatus.uploading, progress: percent), + onDone: _onUploaded, + onFailed: _onUploadFailed, + ); _autosave = DraftAutosave( canSave: _canSave, hasChanges: _hasDraftChanges, @@ -291,6 +342,7 @@ class ComposeController extends Notifier { _preloadSuggestions(); _preloadGroups(); unawaited(_loadUndoSeconds()); + _limitsLoaded = _loadLimits(); final ComposeState initial; switch (intent) { @@ -374,6 +426,7 @@ class ComposeController extends Notifier { state = ComposeState( kind: state.kind, titleKnown: args.intent is! DraftComposeIntent, + limits: state.limits, ); await _loadSource(_formGeneration); } @@ -429,6 +482,8 @@ class ComposeController extends Notifier { signatureText: prepared?.text, ), ); + // 전달하는 원본 첨부의 합계는 서버 한도로 잰다(받지 못하거나 늦으면 대체값) — 잴 첨부가 있을 때만 기다린다(답장은 기다리지 않는다) + if (forward && detail.attachments.isNotEmpty) await _waitLimits(); if (!_alive(generation)) return; List to = const []; List cc = const []; @@ -491,6 +546,8 @@ class ComposeController extends Notifier { bodyHtml: draft.bodyHtml, body: draft.body, ); + // 저장본의 첨부 합계도 서버 한도로 잰다 — 첨부가 있을 때만 기다린다 + if (draft.attachments.isNotEmpty) await _waitLimits(); if (!_alive(generation)) return; ComposeSignature? signature; ComposeQuote? quote; @@ -560,6 +617,7 @@ class ComposeController extends Notifier { size: attachment.size, attachedCount: count, attachedBytes: bytes, + totalMaxBytes: state.limits.totalMaxBytes, ); if (rejected == null) { count++; @@ -598,6 +656,21 @@ class ComposeController extends Notifier { } } + /// 첨부 한도를 [kAttachmentLimitsWait] 까지 기다린다 — 넘으면 그대로 간다(받으면 뒤에 바뀐다) + Future _waitLimits() => + _limitsLoaded.timeout(kAttachmentLimitsWait, onTimeout: () {}); + + /// 첨부 한도(파일당 · 합계) — 받지 못하면 대체값(20 · 25MB)으로 쓴다 + Future _loadLimits() async { + try { + final AttachmentLimits limits = await _repository.getAttachmentLimits(); + if (!ref.mounted || _ended) return; + if (limits != state.limits) state = state.copyWith(limits: limits); + } on Object { + // 대체값으로 쓴다 — 알리지 않는다(뜻밖의 실패도 열기를 막거나 처리되지 않은 오류로 남지 않게) + } + } + // ── 받는 사람 ─────────────────────────────────────── /// 칸의 글이 바뀌었다 — 구분자(쉼표 · 세미콜론 · 줄바꿈, 따옴표 · 꺾쇠 밖)까지는 칩으로, 남은 글은 그대로. 모자라면 서버에 묻는다 @@ -886,8 +959,12 @@ class ComposeController extends Notifier { _emitUnsaved(); } + /// 첨부를 뺀다 — 고른 파일이면 올리던 것을 끊고(✕ = 올리기 취소) 복사본을 지운다 void removeAttachment(int key) { if (!ref.mounted) return; + _uploads.remove(key); + _reuploaded.remove(key); + _lostOnServer.remove(key); state = state.copyWith( attachments: [ for (final ComposeAttachment item in state.attachments) @@ -898,6 +975,264 @@ class ComposeController extends Notifier { _emitUnsaved(); } + // ── 첨부 고르기 · 올리기 ───────────────────────────── + + /// 한도에 세는 첨부의 수 · 바이트 — 보낼 첨부와 올리는 중 · 다시 올릴 수 있는 고른 파일(`ComposeAttachment.countsTowardLimits`) + ({int count, int bytes}) get _attachedTotals { + int count = 0; + int bytes = 0; + for (final ComposeAttachment item in state.attachments) { + if (!item.countsTowardLimits) continue; + count++; + bytes += item.size; + } + return (count: count, bytes: bytes); + } + + /// [파일 첨부] — 고르는 창을 열고, 고른 파일을 사전 검사한 뒤 올린다(차례: 개수 `MAIL_006` → 형식 `FILE_001` → 파일당 → 합계). + /// + /// 이미 20개면 창을 열지 않고 알린다. 기기는 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계 — 열 때의 한도)로 같은 차례로 미리 걸러 + /// 넣을 파일만 복사하고(받지 않을 형식 · 합계를 넘는 파일은 읽지 않는다), 여기서 다시 판정한다. + /// 넣지 못한 파일은 줄로 남기지 않고 알림 하나로 까닭을 모아 알린다. 고르는 · 가져오는 동안은 [보내기]를 막는다([sendBlockReason]). + /// 고른 결과가 온 때 세션이 끝났거나 보내는 중 · 보냄 · 화면이 떠났으면 복사본만 지운다 + Future pickAttachments() async { + if (!ref.mounted || + _ended || + state.status != ComposeStatus.ready || + state.sending || + state.picking) { + return; + } + final void Function(String text) notice = ref.read(mailNoticeSinkProvider); + final ({int count, int bytes}) attached = _attachedTotals; + if (attached.count >= kMailMaxAttachments) { + notice(ApiException.fromCode('MAIL_006').message); + return; + } + final AttachmentLimits limits = state.limits; + final int generation = _formGeneration; + final PickedFileStore files = _files; + state = state.copyWith(picking: true); + PickResult result; + try { + result = await files.pick( + maxBytes: limits.maxBytes, + maxCount: kMailMaxAttachments - attached.count, + maxTotalBytes: math.max(0, limits.totalMaxBytes - attached.bytes), + extensions: kMailAttachmentTypes.keys.toList(), + ); + } on Object { + // 뜻밖의 실패도 [파일 첨부]를 막힌 채 두지 않는다 + result = const PickResult(PickOutcome.failed); + } + // 고르는 사이 세션이 끝났거나, 보내는 중 · 보냄 · 화면이 떠났다 — 붙이지 않고 복사본만 지운다 + if (!_alive(generation) || + !_sameSession() || + _sent || + state.sending || + !_attached) { + for (final PickedFile file in result.files) { + final String? path = file.path; + if (path != null) unawaited(files.delete(path)); + } + if (ref.mounted && !_ended) state = state.copyWith(picking: false); + return; + } + state = state.copyWith(picking: false); + switch (result.outcome) { + case PickOutcome.picked: + _addPicked(result.files, limits, notice); + case PickOutcome.noApp: + case PickOutcome.failed: + notice(MailComposeTexts.pickFailed); + case PickOutcome.timedOut: + // 고른 파일을 가져오다 시한을 넘겼다(고른 앱이 멈춤) — 고르는 창을 열지 못한 것과 가른다 + notice(MailComposeTexts.pickReadFailed); + case PickOutcome.canceled: + case PickOutcome.busy: + break; + } + } + + /// 고른 파일을 고른 차례대로 검사해 붙이고 올리기 차례에 넣는다 — 앞에서 넣은 파일이 개수 · 합계를 늘린다(웹과 같다) + void _addPicked( + List files, + AttachmentLimits limits, + void Function(String text) notice, + ) { + final ({int count, int bytes}) attached = _attachedTotals; + int count = attached.count; + int bytes = attached.bytes; + final Map> rejected = + >{}; + final List added = []; + final List<({int key, String path, int size, String name})> queued = + <({int key, String path, int size, String name})>[]; + for (final PickedFile file in files) { + final String? path = file.path; + final int? size = file.size; + final AttachmentRejection? rejection = precheckPickedFile( + file, + attachedCount: count, + attachedBytes: bytes, + limits: limits, + ); + if (rejection != null || path == null || size == null) { + (rejected[rejection ?? AttachmentRejection.unreadable] ??= []) + .add(file.name); + if (path != null) unawaited(_files.delete(path)); + continue; + } + count++; + bytes += size; + final int key = _nextAttachmentKey++; + added.add( + ComposeAttachment( + key: key, + id: '', + name: file.name, + size: size, + upload: ComposeUploadStatus.waiting, + ), + ); + queued.add((key: key, path: path, size: size, name: file.name)); + } + if (added.isNotEmpty) { + state = state.copyWith( + attachments: List.unmodifiable([ + ...state.attachments, + ...added, + ]), + ); + for (final ({int key, String path, int size, String name}) item + in queued) { + _uploads.add( + item.key, + path: item.path, + name: item.name, + size: item.size, + ); + } + _emitUnsaved(); + } + if (rejected.isNotEmpty) notice(pickRejectionNotice(rejected, limits)); + } + + /// 올리지 못한 첨부의 [다시 시도] — 복사본으로 다시 올린다(다시 고르지 않는다). 복사본이 없으면 그 까닭으로 바꾼다 + void retryAttachment(int key) { + if (!ref.mounted || _ended || state.sending) return; + final ComposeAttachment? item = _attachmentOf(key); + if (item == null || + item.upload != ComposeUploadStatus.failed || + !(item.failure?.canRetry ?? false)) { + return; + } + _reuploaded.remove(key); + _setUpload(key, ComposeUploadStatus.waiting); + if (!_uploads.requeue(key)) { + // 다시 넣지 못했다(복사본 차례가 없음) — 서버가 잃은 줄이면 "찾지 못함"도 붙어 막힌다(다른 실패와 같은 규칙) + _onUploadFailed(key, ComposeUploadFailure.copyGone); + } + } + + /// 올리는 중 나가기 확인의 [나가기] — 올리던 · 기다리던 첨부를 빼고 그만둔다(다 올린 첨부는 그대로 저장한다) + void dropUploadingAttachments() { + if (!ref.mounted) return; + final Set dropped = { + for (final ComposeAttachment item in state.attachments) + if (item.uploading) item.key, + }; + if (dropped.isEmpty) return; + _uploads.removeAll(dropped); + _reuploaded.removeAll(dropped); + _lostOnServer.removeAll(dropped); + state = state.copyWith( + attachments: [ + for (final ComposeAttachment item in state.attachments) + if (!dropped.contains(item.key)) item, + ], + ); + _emitUnsaved(); + } + + ComposeAttachment? _attachmentOf(int key) { + for (final ComposeAttachment item in state.attachments) { + if (item.key == key) return item; + } + return null; + } + + /// 그 줄만 바꾼다 — 이미 뺀 줄이면 아무것도 하지 않는다 + void _replaceAttachment( + int key, + ComposeAttachment Function(ComposeAttachment item) change, + ) { + final List current = state.attachments; + final int index = current.indexWhere( + (ComposeAttachment item) => item.key == key, + ); + if (index == -1) return; + final ComposeAttachment next = change(current[index]); + if (next == current[index]) return; + state = state.copyWith( + attachments: List.unmodifiable( + List.of(current)..[index] = next, + ), + ); + } + + /// 올리기 소식 — 그 줄의 상태를 바꾼다(다 올리기 전에는 id 를 비운다) + void _setUpload( + int key, + ComposeUploadStatus status, { + int progress = 0, + ComposeUploadFailure? failure, + }) { + if (!ref.mounted || _ended) return; + _replaceAttachment( + key, + (ComposeAttachment item) => + item.withUpload(status, progress: progress, failure: failure), + ); + _emitUnsaved(); + } + + /// 올리지 못했다 — 서버가 잃은(`MAIL_004`) 파일을 다시 올리다 실패했으면 "찾지 못함"도 붙여 빼거나 다시 올리기 전까지 막는다 + /// (복사본이 없어졌으면 [다시 시도]도 없다 — 빼기만) + void _onUploadFailed(int key, ComposeUploadFailure failure) { + if (!ref.mounted || _ended) return; + final bool lost = _lostOnServer.contains(key); + _replaceAttachment(key, (ComposeAttachment item) { + final ComposeAttachment failed = item.withUpload( + ComposeUploadStatus.failed, + failure: failure, + ); + return lost + ? failed.copyWith(problem: ComposeAttachmentProblem.missing) + : failed; + }); + _emitUnsaved(); + } + + /// 다 올렸다 — 서버의 첨부(id · 서버가 정리한 이름 · 크기)로 저장 · 보내기에 싣고 줄에도 그 이름을 보인다(바꿈 — 자동 저장). + /// 고른 앱이 255자를 넘는 이름을 주면 서버가 줄여 저장한다 — 줄이기 전 이름을 실으면 저장 · 발송이 `VAL_001` 이다(웹도 응답의 이름을 싣는다) + void _onUploaded(int key, MessageAttachment attachment) { + if (!ref.mounted || _ended) return; + _lostOnServer.remove(key); + _replaceAttachment( + key, + (ComposeAttachment item) => item.withUpload( + ComposeUploadStatus.done, + id: attachment.id, + progress: 100, + name: attachment.name, + size: attachment.size, + ), + ); + _autosave.touch(); + _emitUnsaved(); + } + void removeLargeFile(String id) { if (!ref.mounted) return; state = state.copyWith( @@ -910,7 +1245,8 @@ class ComposeController extends Notifier { _emitUnsaved(); } - /// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일. 막지 않으면 null + /// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일, 마지막으로 올리는 중 · 가져오는 중인 첨부 + /// (웹 `sendBlockOf` 와 같은 차례 — 가져오는 중은 앱에서 더함: 드라이브 파일을 받는 사이 누르면 그 파일 없이 나가지 않게). 막지 않으면 null String? get sendBlockReason { final List large = state.largeFiles; if (large.any((DraftLargeFile f) => f.infected)) { @@ -929,6 +1265,8 @@ class ComposeController extends Notifier { )) { return MailComposeTexts.largeUploading; } + if (state.uploading) return MailComposeTexts.attachmentsUploading; + if (state.picking) return MailComposeTexts.attachmentsPicking; return null; } @@ -940,16 +1278,40 @@ class ComposeController extends Notifier { (DraftLargeFile f) => f.status == DraftLargeFileStatus.missing, ); - /// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 "찾지 못함"으로(웹과 같다) + /// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 손본다(웹 `markMissing` 과 같은 결): + /// 이 화면에서 고른 파일은 복사본으로 한 번 다시 올리고, 다시 올릴 수 없으면(이미 한 번 다시 올림 · 화면이 닫혀 차례가 닫힘) + /// 원본 · 저장본의 첨부처럼 "찾지 못함"으로 둔다 — 빼기 전까지 저장 · 보내기를 막는다(모르는 사이 첨부 없이 나가지 않게). + /// 다시 올리다 실패하면(복사본이 없어짐 등) 그때 "찾지 못함"을 붙인다([_onUploadFailed]) void _markAttachmentsMissing() { + // 다시 올릴 줄 — 그 전의 모습(다시 올리지 못하면 그 모습에 "찾지 못함"을 붙인다) + final Map reupload = {}; + final List next = []; + for (final ComposeAttachment item in state.attachments) { + if (!item.sendable) { + next.add(item); + } else if (item.upload != null && + _uploads.hasCopy(item.key) && + !_reuploaded.contains(item.key)) { + reupload[item.key] = item; + next.add(item.withUpload(ComposeUploadStatus.waiting)); + } else { + next.add(item.copyWith(problem: ComposeAttachmentProblem.missing)); + } + } state = state.copyWith( - attachments: [ - for (final ComposeAttachment item in state.attachments) - item.sendable - ? item.copyWith(problem: ComposeAttachmentProblem.missing) - : item, - ], + attachments: List.unmodifiable(next), ); + for (final MapEntry entry in reupload.entries) { + _reuploaded.add(entry.key); + _lostOnServer.add(entry.key); + if (!_uploads.requeue(entry.key)) { + _replaceAttachment( + entry.key, + (ComposeAttachment _) => + entry.value.copyWith(problem: ComposeAttachmentProblem.missing), + ); + } + } _emitUnsaved(); } @@ -1011,7 +1373,7 @@ class ComposeController extends Notifier { (TextEditingController input) => input.text.trim().isNotEmpty, ); - /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) + /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) · 올리는 중인 첨부 bool get hasUnsavedChanges { if (!ref.mounted || _ended || @@ -1022,7 +1384,8 @@ class ComposeController extends Notifier { return _hasDraftChanges() || _typingRecipient || _hasMissingAttachments || - _hasMissingLargeFiles; + _hasMissingLargeFiles || + state.uploading; } void _emitUnsaved() { @@ -1146,6 +1509,9 @@ class ComposeController extends Notifier { if (!_alive(generation)) return; _baseline = snapshot; _savedOnce = true; + _reuploaded.removeAll([ + for (final ComposeAttachment item in carried) item.key, + ]); _applySavedAttachments(carried, saved.attachments); // 저장한 차례 — 나가기 확인 창이 "그사이 저장이 끝났다"를 이것으로 안다 state = state.copyWith(saveCount: state.saveCount + 1); @@ -1231,6 +1597,8 @@ class ComposeController extends Notifier { if (!_attached) return; _attached = false; _cancelSearch(); + // 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다) + _uploads.close(); if (!_sent && !_sendingNow) _autosave.stop(); final Set removed = {..._replacedIds}; if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) { @@ -1536,6 +1904,7 @@ class ComposeController extends Notifier { _ended = true; _autosave.stop(); _cancelSearch(); + _uploads.close(); if (!ref.mounted) return; // 잃을 것이 없다고 본다 — 화면이 묻지 않고 닫히게(다른 직원의 임시보관함에 남기지 않는다). 나가기 확인 창도 알리지 않고 닫는다 state = state.copyWith(ended: true); @@ -1545,6 +1914,7 @@ class ComposeController extends Notifier { void _dispose() { _autosave.dispose(); _cancelSearch(); + _uploads.close(); subject.dispose(); body.dispose(); for (final TextEditingController input in inputs.values) { @@ -1578,6 +1948,31 @@ Future _undoSend( } } +/// 고른 파일 가운데 넣지 못한 것의 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다(알림은 한 번에 하나만 보인다). +/// 개수 · 형식은 사전 문구(`MAIL_006` · `FILE_001`), 크기는 서버 한도로 쓴 글, 합계는 넣지 못한 첫 파일 이름(여럿이면 "외 N개") +String pickRejectionNotice( + Map> rejected, + AttachmentLimits limits, +) => [ + for (final MapEntry> entry + in rejected.entries) + switch (entry.key) { + AttachmentRejection.tooMany => ApiException.fromCode('MAIL_006').message, + AttachmentRejection.notAllowed => ApiException.fromCode( + 'FILE_001', + ).message, + AttachmentRejection.tooLarge => MailComposeTexts.fileTooLarge( + formatMegabytes(limits.maxBytes), + ), + AttachmentRejection.unreadable => MailComposeTexts.pickReadFailed, + AttachmentRejection.overTotal => MailComposeTexts.overTotal( + formatMegabytes(limits.totalMaxBytes), + entry.value.first, + others: entry.value.length - 1, + ), + }, +].join('\n'); + /// 받는 사람 칸의 이름 — "받는 사람" · "참조" · "숨은참조" String fieldLabelOf(RecipientFieldKind kind) => switch (kind) { RecipientFieldKind.to => MailComposeTexts.to, diff --git a/flutter/lib/features/mail/presentation/providers/compose_state.dart b/flutter/lib/features/mail/presentation/providers/compose_state.dart index 5d6bebb..791e839 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_state.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_state.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_reply.dart'; import 'package:arasystem_app/features/mail/domain/compose_request.dart'; @@ -39,7 +40,7 @@ enum RecipientFieldKind { to, cc, bcc } /// 길이가 한도를 넘은 칸 — 제목 255자 · 본문(인용을 다 줄여도 넘음) enum ComposeLengthError { subject, body } -/// 첨부 줄의 문제 +/// 첨부 줄의 문제 — 원본 · 저장본에 있던 첨부 enum ComposeAttachmentProblem { /// 함께 보낼 수 없는 형식 · 개수 · 합계(사전 검사) — 싣지 않는다 unsendable, @@ -48,7 +49,57 @@ enum ComposeAttachmentProblem { missing, } -/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 이미 있던 첨부(앱은 첫 판에서 새로 올리지 않는다) +/// 이 화면에서 고른 파일의 올리기 상태 (보드 `App1Compose` 「올리는 중 40%」) +enum ComposeUploadStatus { + /// 차례를 기다린다(한 번에 2개씩 올린다) + waiting, + + /// 올리는 중 — 진행 % + uploading, + + /// 다 올렸다 — 저장 · 보내기에 싣는다 + done, + + /// 올리지 못했다 — 사전 문구 + [다시 시도](다시 해도 같은 실패면 빼기만) + failed, +} + +/// 올리지 못한 까닭 +@immutable +class ComposeUploadFailure { + const ComposeUploadFailure({required this.code, required this.canRetry}); + + /// 서버 · 연결 실패 — 형식 · 크기 거절(`FILE_001` · `FILE_002`)과 요청 거절(`VAL_001`)은 다시 올려도 같아 [다시 시도]가 없다 + factory ComposeUploadFailure.of(String code) => + ComposeUploadFailure(code: code, canRetry: !_finalCodes.contains(code)); + + /// 복사본이 없어졌다(기기가 임시 폴더를 비웠다) — 다시 골라야 한다 + static const ComposeUploadFailure copyGone = ComposeUploadFailure( + code: null, + canRetry: false, + ); + + static const Set _finalCodes = { + 'FILE_001', + 'FILE_002', + 'VAL_001', + }; + + /// 사전 문구의 코드 — null 이면 복사본이 없어졌다 + final String? code; + final bool canRetry; + + @override + bool operator ==(Object other) => + other is ComposeUploadFailure && + other.code == code && + other.canRetry == canRetry; + + @override + int get hashCode => Object.hash(code, canRetry); +} + +/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 있던 첨부([upload] 가 null), 또는 이 화면에서 고른 파일([upload] 가 있다) @immutable class ComposeAttachment { const ComposeAttachment({ @@ -57,17 +108,44 @@ class ComposeAttachment { required this.name, required this.size, this.problem, + this.upload, + this.progress = 0, + this.failure, }); /// 화면 안의 이름 — 저장할 때마다 바뀌는 [id] 와 달리 그대로다 final int key; + + /// 서버의 첨부 id — 고른 파일은 다 올리기 전에는 빈 글 final String id; final String name; final int size; final ComposeAttachmentProblem? problem; - /// 저장 · 보내기에 싣는다 - bool get sendable => problem == null; + /// 이 화면에서 고른 파일의 올리기 상태 — 원본 · 저장본의 첨부는 null + final ComposeUploadStatus? upload; + + /// 올리는 중의 비율(0 ~ 100) + final int progress; + + /// 올리지 못한 까닭 — [ComposeUploadStatus.failed] 일 때만 + final ComposeUploadFailure? failure; + + /// 저장 · 보내기에 싣는다 — 문제가 없고, 고른 파일이면 다 올렸다 + bool get sendable => + problem == null && (upload == null || upload == ComposeUploadStatus.done); + + /// 올리는 중이거나 차례를 기다린다 — 보내기를 막고, 나가기는 따로 묻는다 + bool get uploading => + upload == ComposeUploadStatus.waiting || + upload == ComposeUploadStatus.uploading; + + /// 개수 · 합계 한도에 센다 — 보낼 첨부와, 고른 파일 가운데 다시 올릴 수 있는 것(올리지 못했어도 [다시 시도]가 있으면). + /// 함께 보낼 수 없음 · 찾지 못함(원본 · 다시 올릴 수 없는 고른 파일) · 다시 올릴 수 없는 실패는 세지 않는다 + bool get countsTowardLimits { + if (upload == ComposeUploadStatus.failed) return failure?.canRetry ?? false; + return problem == null; + } ComposeAttachment copyWith({String? id, ComposeAttachmentProblem? problem}) => ComposeAttachment( @@ -76,8 +154,30 @@ class ComposeAttachment { name: name, size: size, problem: problem ?? this.problem, + upload: upload, + progress: progress, + failure: failure, ); + /// 고른 파일의 올리기 상태를 바꾼다 — 다 올리기 전에는 id 를 비운다(옛 id 를 싣지 않게). + /// 다 올리면 서버가 정리한 이름 · 크기([name] · [size])로 바꾼다. "찾지 못함"은 지운다 — 다시 올리기 · 실패가 새 상태다(실패에 다시 붙이는 것은 쓰기 화면) + ComposeAttachment withUpload( + ComposeUploadStatus status, { + String id = '', + int progress = 0, + ComposeUploadFailure? failure, + String? name, + int? size, + }) => ComposeAttachment( + key: key, + id: id, + name: name ?? this.name, + size: size ?? this.size, + upload: status, + progress: progress, + failure: failure, + ); + @override bool operator ==(Object other) => other is ComposeAttachment && @@ -85,10 +185,14 @@ class ComposeAttachment { other.id == id && other.name == name && other.size == size && - other.problem == problem; + other.problem == problem && + other.upload == upload && + other.progress == progress && + other.failure == failure; @override - int get hashCode => Object.hash(key, id, name, size, problem); + int get hashCode => + Object.hash(key, id, name, size, problem, upload, progress, failure); } /// 쓰기 화면의 상태 — 받는 사람 칩 · 첨부 · 서명 · 인용 · 칸 안내. 제목 · 본문 · 칸에 쓰는 글은 컨트롤러의 글 칸(TextEditingController)에 있다 @@ -118,6 +222,8 @@ class ComposeState { this.unsaved = false, this.saveCount = 0, this.ended = false, + this.picking = false, + this.limits = kFallbackAttachmentLimits, }); final ComposeStatus status; @@ -170,6 +276,16 @@ class ComposeState { /// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 알리지도 않는다 final bool ended; + /// 고르는 창을 열었거나 고른 파일을 가져오는 중 — [파일 첨부]를 막고 위에 진행 막대 + final bool picking; + + /// 첨부 한도 — `GET /api/mail-quota`(받기 전 · 받지 못하면 20 · 25MB). 첨부 머리의 한도 글 + final AttachmentLimits limits; + + /// 고른 파일을 올리는 중이거나 차례를 기다린다 + bool get uploading => + attachments.any((ComposeAttachment item) => item.uploading); + List tokensOf(RecipientFieldKind kind) => switch (kind) { RecipientFieldKind.to => to, RecipientFieldKind.cc => cc, @@ -228,6 +344,8 @@ class ComposeState { bool? unsaved, int? saveCount, bool? ended, + bool? picking, + AttachmentLimits? limits, }) => ComposeState( status: status ?? this.status, error: error ?? this.error, @@ -254,5 +372,7 @@ class ComposeState { unsaved: unsaved ?? this.unsaved, saveCount: saveCount ?? this.saveCount, ended: ended ?? this.ended, + picking: picking ?? this.picking, + limits: limits ?? this.limits, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/compose_bars.dart b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart index 5753c77..931df37 100644 --- a/flutter/lib/features/mail/presentation/widgets/compose_bars.dart +++ b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart @@ -26,7 +26,7 @@ class ComposeAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; final VoidCallback onClose; - /// [파일 첨부] — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중) + /// [파일 첨부](고르는 창) — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중 · 고르는 중 · 나가는 중) final VoidCallback? onAttach; /// [보내기] — null 이면 꺼진 단추(받는 중 · 받지 못함) diff --git a/flutter/lib/features/mail/presentation/widgets/compose_sections.dart b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart index 21870f0..990b0f6 100644 --- a/flutter/lib/features/mail/presentation/widgets/compose_sections.dart +++ b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart @@ -6,6 +6,7 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; @@ -28,15 +29,19 @@ const double _quoteLineHeight = 1.7; // ── 첨부 ───────────────────────────────────────────── -/// 첨부 칸 — "첨부 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · ✕ (보드 `App1Compose` 「답장 · 첨부」). +/// 첨부 칸 — "첨부 N개" · 오른쪽에 한도 글 · 줄마다 파일 아이콘 · 이름 · 크기(또는 올리는 진행) · ✕ (보드 `App1Compose` 「답장 · 첨부」). /// -/// 앱은 첫 판에서 새로 올리지 않는다 — 전달하는 원본 · 이어 쓰는 저장본에 이미 있던 첨부만 싣고 뺀다. +/// - 이 화면에서 고른 파일: 올리는 동안 크기 자리에 진행 막대 · "올리는 중 N%" 이고 ✕ 는 올리기 취소다. +/// 올리지 못했으면 "올리지 못했습니다." · 사전 문구 · [다시 시도](다시 해도 같은 실패면 빼기만). +/// - 전달하는 원본 · 이어 쓰는 저장본의 첨부 · 대용량 파일은 싣고 빼기만 한다. /// 실을 수 없는 첨부 · 서버가 찾지 못한 첨부(`MAIL_004`) · 웹에서 올리다 멈췄거나 지웠거나 위험한 대용량 파일은 줄 아래에 빨갛게 까닭을 적는다 class ComposeAttachmentsSection extends StatelessWidget { const ComposeAttachmentsSection({ required this.attachments, required this.largeFiles, + required this.limits, required this.onRemoveAttachment, + required this.onRetryAttachment, required this.onRemoveLargeFile, this.enabled = true, super.key, @@ -44,10 +49,16 @@ class ComposeAttachmentsSection extends StatelessWidget { final List attachments; final List largeFiles; + + /// 머리 오른쪽의 한도 글 — 서버 값(받지 못하면 20 · 25MB) + final AttachmentLimits limits; final ValueChanged onRemoveAttachment; + + /// 올리지 못한 첨부의 [다시 시도] + final ValueChanged onRetryAttachment; final ValueChanged onRemoveLargeFile; - /// 거짓이면 ✕ 를 막는다(보내는 중) + /// 거짓이면 ✕ · [다시 시도]를 막는다(보내는 중) final bool enabled; @override @@ -73,35 +84,42 @@ class ComposeAttachmentsSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Semantics( - header: true, - child: Text( - MailComposeTexts.attachmentsTitle( - attachments.length + largeFiles.length, + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Semantics( + header: true, + child: Text( + MailComposeTexts.attachmentsTitle( + attachments.length + largeFiles.length, + ), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: AppTypography.semiBold, - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(width: AppSpacing.s8), + Expanded( + child: Text( + MailComposeTexts.attachmentLimits( + formatMegabytes(limits.maxBytes), + formatMegabytes(limits.totalMaxBytes), + ), + key: const ValueKey('compose-attachment-limits'), + textAlign: TextAlign.end, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: extra.textMuted, + ), + ), ), - ), + ], ), for (final ComposeAttachment item in attachments) ...[ const SizedBox(height: AppSpacing.s8), - _ComposeFileRow( - key: ValueKey('compose-attachment-${item.key}'), - icon: Icons.insert_drive_file_outlined, - name: item.name, - detail: formatFileSize(item.size), - problem: switch (item.problem) { - ComposeAttachmentProblem.unsendable => - MailComposeTexts.unsendable, - ComposeAttachmentProblem.missing => ApiException.fromCode( - 'MAIL_004', - ).message, - null => null, - }, - onRemove: enabled ? () => onRemoveAttachment(item.key) : null, - ), + _attachmentRow(item), ], for (final DraftLargeFile file in largeFiles) ...[ const SizedBox(height: AppSpacing.s8), @@ -109,11 +127,16 @@ class ComposeAttachmentsSection extends StatelessWidget { key: ValueKey('compose-large-file-${file.id}'), icon: Icons.cloud_outlined, name: file.name ?? MailComposeTexts.largeUnknownName, - detail: [ - if (file.size case final int size) formatFileSize(size), - MailReaderTexts.largeFile, - ].join(' · '), + below: _DetailText( + [ + if (file.size case final int size) formatFileSize(size), + MailReaderTexts.largeFile, + ].join(' · '), + ), problem: largeFileProblemOf(file), + removeTooltip: MailComposeTexts.remove( + file.name ?? MailComposeTexts.largeUnknownName, + ), onRemove: enabled ? () => onRemoveLargeFile(file.id) : null, ), ], @@ -121,6 +144,66 @@ class ComposeAttachmentsSection extends StatelessWidget { ), ); } + + Widget _attachmentRow(ComposeAttachment item) { + final IconData icon = isImageAttachment(item.name) + ? Icons.image_outlined + : Icons.insert_drive_file_outlined; + final VoidCallback? remove = enabled + ? () => onRemoveAttachment(item.key) + : null; + final Key key = ValueKey('compose-attachment-${item.key}'); + if (item.uploading) { + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: _UploadProgress( + name: item.name, + percent: item.upload == ComposeUploadStatus.uploading + ? item.progress + : 0, + ), + removeTooltip: MailComposeTexts.cancelUpload(item.name), + onRemove: remove, + ); + } + if (item.upload == ComposeUploadStatus.failed) { + final ComposeUploadFailure? failure = item.failure; + final String? code = failure?.code; + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: const _DetailText(MailComposeTexts.uploadFailed, error: true), + problem: code == null + ? MailComposeTexts.uploadCopyGone + : ApiException.fromCode(code).message, + retryLabel: MailComposeTexts.retryUploadLabel(item.name), + onRetry: (failure?.canRetry ?? false) && enabled + ? () => onRetryAttachment(item.key) + : null, + showRetry: failure?.canRetry ?? false, + removeTooltip: MailComposeTexts.remove(item.name), + onRemove: remove, + ); + } + return _ComposeFileRow( + key: key, + icon: icon, + name: item.name, + below: _DetailText(formatFileSize(item.size)), + problem: switch (item.problem) { + ComposeAttachmentProblem.unsendable => MailComposeTexts.unsendable, + ComposeAttachmentProblem.missing => ApiException.fromCode( + 'MAIL_004', + ).message, + null => null, + }, + removeTooltip: MailComposeTexts.remove(item.name), + onRemove: remove, + ); + } } /// 대용량 파일 줄 아래의 까닭 — 보낼 수 있으면 null (차례: 위험 → 지움 → 올리다 멈춤) @@ -133,30 +216,116 @@ String? largeFileProblemOf(DraftLargeFile file) { }; } -/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 크기 · ✕(48), 문제가 있으면 그 아래 빨간 글 -class _ComposeFileRow extends StatelessWidget { - const _ComposeFileRow({ - required this.icon, - required this.name, - required this.detail, - required this.onRemove, - this.problem, - super.key, - }); +/// 이름 아래 한 줄 — 크기 · "대용량 첨부" · "올리지 못했습니다."(빨강) +class _DetailText extends StatelessWidget { + const _DetailText(this.text, {this.error = false}); + + final String text; + final bool error; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Text( + text, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: error ? AppTypography.semiBold : AppTypography.regular, + color: error ? theme.colorScheme.error : extra.textMuted, + ), + ); + } +} + +/// 올리는 줄의 진행 — 막대(4) · "올리는 중 40%" (보드). 막대는 화면 낭독기에 "{이름} 올리는 중" 과 비율로 읽힌다 +class _UploadProgress extends StatelessWidget { + const _UploadProgress({required this.name, required this.percent}); - final IconData icon; final String name; - final String detail; - final String? problem; + final int percent; - /// ✕ — null 이면 꺼진 단추 - final VoidCallback? onRemove; + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.s4), + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xs), + child: LinearProgressIndicator( + value: percent / 100, + minHeight: AppSize.progressBarHeight, + backgroundColor: scheme.surfaceContainerHighest, + color: scheme.primary, + semanticsLabel: MailComposeTexts.uploading(name), + ), + ), + ), + const SizedBox(width: AppSpacing.s8), + ExcludeSemantics( + child: Text( + MailComposeTexts.uploadProgress(percent), + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: scheme.onPrimaryContainer, + fontFeatures: _tabular, + ), + ), + ), + ], + ), + ); + } +} + +/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 아래 줄 · ✕(48), 문제가 있으면 그 아래 빨간 글과 [다시 시도] +class _ComposeFileRow extends StatelessWidget { + const _ComposeFileRow({ + required this.icon, + required this.name, + required this.below, + required this.removeTooltip, + required this.onRemove, + this.problem, + this.retryLabel, + this.onRetry, + this.showRetry = false, + super.key, + }); + + final IconData icon; + final String name; + + /// 이름 아래 — 크기 · 올리는 진행 · "올리지 못했습니다." + final Widget below; + final String? problem; + + /// ✕ 의 이름(화면 낭독기) — "‘이름’ 빼기" · "‘이름’ 올리기 취소" + final String removeTooltip; + + /// ✕ — null 이면 꺼진 단추 + final VoidCallback? onRemove; + + /// [다시 시도]의 화면 낭독기 이름 + final String? retryLabel; + + /// [다시 시도] — null 이면 꺼진 단추(보내는 중) + final VoidCallback? onRetry; + + /// [다시 시도]를 보인다 + final bool showRetry; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final ColorScheme scheme = theme.colorScheme; - final AppExtraColors extra = theme.extension()!; final String? reason = problem; final bool failed = reason != null; return Container( @@ -200,7 +369,7 @@ class _ComposeFileRow extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.s4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Text( @@ -211,13 +380,7 @@ class _ComposeFileRow extends StatelessWidget { fontWeight: AppTypography.medium, ), ), - Text( - detail, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: AppTypography.regular, - color: extra.textMuted, - ), - ), + below, if (reason != null) Padding( padding: const EdgeInsets.only(top: AppSpacing.s4), @@ -228,13 +391,33 @@ class _ComposeFileRow extends StatelessWidget { ), ), ), + if (showRetry) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: scheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s8, + ), + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + onPressed: onRetry, + child: Text( + MailComposeTexts.retryUpload, + semanticsLabel: retryLabel, + ), + ), + ), ], ), ), ), IconButton( icon: const Icon(Icons.close, size: AppSize.iconSmall), - tooltip: MailComposeTexts.remove(name), + tooltip: removeTooltip, color: theme.colorScheme.onSurfaceVariant, onPressed: onRemove, ), diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 549a6ad..ff0ecf3 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -5,6 +5,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/user_agent.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/storage/first_run_marker.dart'; import 'package:arasystem_app/core/storage/secure_store.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; @@ -41,9 +42,12 @@ Future main() async { final ({String? appVersion, String userAgent}) device = await loadAppDeviceInfo(); - // 지난 실행이 받아 둔 파일(메일 첨부)을 지운다 — 기다리지 않는다(저장소가 차례를 지켜 새로 받는 파일은 지우지 않는다) + // 지난 실행이 받아 둔 파일(메일 첨부)과 고른 파일의 복사본(쓰기 화면의 첨부)을 지운다 — 기다리지 않는다 + // (저장소가 차례를 지켜 새로 받는 파일 · 새로 고른 파일은 지우지 않는다) final DownloadedFileStore downloadedFiles = CacheDirFileStore(); unawaited(downloadedFiles.purge()); + final PickedFileStore pickedFiles = PlatformPickedFileStore(); + unawaited(pickedFiles.purge()); runApp( ProviderScope( @@ -54,6 +58,7 @@ Future main() async { userAgentProvider.overrideWithValue(device.userAgent), appVersionProvider.overrideWithValue(device.appVersion), downloadedFileStoreProvider.overrideWithValue(downloadedFiles), + pickedFileStoreProvider.overrideWithValue(pickedFiles), ], child: const App(), ), diff --git a/flutter/test/core/files/picked_files_test.dart b/flutter/test/core/files/picked_files_test.dart new file mode 100644 index 0000000..24ef864 --- /dev/null +++ b/flutter/test/core/files/picked_files_test.dart @@ -0,0 +1,338 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 고른 파일(`PlatformPickedFileStore`) — 채널의 답 읽기 · 복사본 폴더 안에서만 지우기 · 차례. +/// 채널은 시험의 가짜 처리기로, 복사본은 시험마다 만든 임시 폴더(실제 파일 입출력)로 본다 — Kotlin 이 만드는 자리와 같은 꼴 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const MethodChannel channel = PlatformFileOpener.channel; + late Directory base; + late PlatformPickedFileStore store; + final List calls = []; + + /// 채널의 답 — 시험이 바꿔 끼운다 + late Future Function(MethodCall call) answer; + + setUp(() async { + base = await Directory.systemTemp.createTemp('arasystem-picked-test-'); + store = PlatformPickedFileStore(baseDirectory: () async => base); + calls.clear(); + answer = (MethodCall call) async => { + 'outcome': 'canceled', + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) { + calls.add(call); + return answer(call); + }); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + if (await base.exists()) await base.delete(recursive: true); + }); + + String rootPath() => '${base.path}/$kPickedFilesFolder'; + + /// Kotlin 이 만드는 복사본 — `uploads/<무작위>/<이름>` + Future copyOf(String folder, String name, List bytes) async { + final File file = File('${rootPath()}/$folder/$name'); + await file.create(recursive: true); + await file.writeAsBytes(bytes); + return file.path; + } + + group('채널의 답 읽기', () { + test('고른 차례대로 — 다 복사한 것 · 크기 초과 · 가져오지 않음 · 형식 · 합계로 거름 · 읽지 못함', () { + final PickResult result = PickResult.parse({ + 'outcome': 'picked', + 'files': [ + { + 'name': '견적서.pdf', + 'size': 3, + 'status': 'ok', + 'path': '/c/uploads/a/견적서.pdf', + }, + { + 'name': '영상.mp4', + 'size': 900000000, + 'status': 'tooLarge', + 'path': null, + }, + { + 'name': 'c.png', + 'size': null, + 'status': 'skipped', + }, + { + 'name': 'IMG_0001.HEIC', + 'size': 3, + 'status': 'notAllowed', + }, + { + 'name': '도면.pdf', + 'size': 9, + 'status': 'overTotal', + }, + {'name': null, 'status': 'failed'}, + ], + }); + expect(result.outcome, PickOutcome.picked); + expect(result.files, [ + const PickedFile( + name: '견적서.pdf', + status: PickedFileStatus.ok, + size: 3, + path: '/c/uploads/a/견적서.pdf', + ), + const PickedFile( + name: '영상.mp4', + status: PickedFileStatus.tooLarge, + size: 900000000, + ), + const PickedFile(name: 'c.png', status: PickedFileStatus.skipped), + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.notAllowed, + size: 3, + ), + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 9, + ), + const PickedFile( + name: kFallbackFileName, + status: PickedFileStatus.failed, + ), + ]); + }); + + test('다 복사했다는데 경로 · 크기가 없거나 모양이 틀리면 읽지 못한 파일로 본다', () { + final PickResult result = PickResult.parse({ + 'outcome': 'picked', + 'files': [ + {'name': 'a.pdf', 'size': 3, 'status': 'ok'}, + { + 'name': 'b.pdf', + 'status': 'ok', + 'path': '/c/uploads/b/b.pdf', + }, + {'name': 'c.pdf', 'status': 'unknown'}, + '모양이 틀림', + ], + }); + expect( + result.files.map((PickedFile file) => file.status), + everyElement(PickedFileStatus.failed), + ); + expect(result.files.first.name, 'a.pdf'); + }); + + test('고르지 않음 · 열려 있음 · 앱 없음 · 실패 · 모르는 답', () { + PickOutcome outcomeOf(Object? raw) => PickResult.parse(raw).outcome; + expect( + outcomeOf({'outcome': 'canceled'}), + PickOutcome.canceled, + ); + expect( + outcomeOf({'outcome': 'busy'}), + PickOutcome.busy, + ); + expect( + outcomeOf({'outcome': 'noApp'}), + PickOutcome.noApp, + ); + expect( + outcomeOf({'outcome': 'failed'}), + PickOutcome.failed, + ); + expect(outcomeOf({'outcome': '?'}), PickOutcome.failed); + expect(outcomeOf(null), PickOutcome.failed); + expect( + outcomeOf({'outcome': 'picked', 'files': 3}), + PickOutcome.failed, + ); + }); + + test('보이는 이름 — 경로 앞부분 · 제어 · 방향 제어 글자를 지우고 앞뒤 공백을 지운다(다른 글자는 그대로)', () { + expect(displayFileName(' 회의:결과.pdf '), '회의:결과.pdf'); + expect(displayFileName('a/b\\보고서.hwp'), '보고서.hwp'); + expect(displayFileName('invoice\u202Etxt.exe'), 'invoicetxt.exe'); + expect(displayFileName('a\u0000b\nc.png'), 'abc.png'); + expect(displayFileName('\u200B '), kFallbackFileName); + expect(displayFileName(null), kFallbackFileName); + }); + }); + + group('플랫폼 고르기', () { + test('파일 채널의 pick — 남은 개수 · 파일당 · 남은 합계 · 허용 확장자를 넘기고 답을 읽는다', () async { + answer = (MethodCall call) async => { + 'outcome': 'picked', + 'files': [ + { + 'name': 'a.pdf', + 'size': 1, + 'status': 'ok', + 'path': '${rootPath()}/x/a.pdf', + }, + ], + }; + final PickResult result = await store.pick( + maxBytes: 20, + maxCount: 3, + maxTotalBytes: 25, + extensions: ['pdf', 'hwp'], + ); + expect(calls.single.method, PlatformPickedFileStore.pickMethod); + expect(calls.single.arguments, { + 'maxBytes': 20, + 'maxCount': 3, + 'maxTotalBytes': 25, + 'extensions': ['pdf', 'hwp'], + }); + expect(result.files.single.path, '${rootPath()}/x/a.pdf'); + }); + + test( + '기기가 답하지 않으면 시한 뒤 timedOut 으로 끝내고 차례를 넘긴다 — 다음 고르기 · 정리가 묶이지 않는다', + () async { + // 고른 앱이 읽기에서 멈춰 기기가 답하지 않는다 — 첫 고르기의 답은 오지 않는다 + final Completer stuck = Completer(); + answer = (MethodCall call) => calls.length == 1 + ? stuck.future + : Future.value({'outcome': 'canceled'}); + final PlatformPickedFileStore quick = PlatformPickedFileStore( + baseDirectory: () async => base, + pickTimeout: const Duration(milliseconds: 20), + ); + Future pick() => quick.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + ); + + expect((await pick()).outcome, PickOutcome.timedOut); + expect((await pick()).outcome, PickOutcome.canceled); + expect(calls, hasLength(2)); + await copyOf('f1', 'a.pdf', [1]); + await quick.purge(); + expect(await Directory(rootPath()).exists(), isFalse); + // 늦게 온 답은 버린다(그 복사본은 다음 정리에 지워진다) + stuck.complete({'outcome': 'canceled'}); + expect(kPickTimeout, const Duration(minutes: 10)); + }, + ); + + test('채널이 없거나(iOS) 던지면 실패', () async { + answer = (MethodCall call) async => throw PlatformException(code: 'boom'); + expect( + (await store.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + )).outcome, + PickOutcome.failed, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect( + (await store.pick( + maxBytes: 1, + maxCount: 1, + maxTotalBytes: 1, + extensions: ['pdf'], + )).outcome, + PickOutcome.failed, + ); + }); + + test('모두 지우기는 열린 고르기가 끝난 뒤에 돈다 — 고르는 동안 들어온 정리가 막 고른 복사본까지 지운다', () async { + final Completer picker = Completer(); + answer = (MethodCall call) => picker.future; + final Future picking = store.pick( + maxBytes: 9, + maxCount: 1, + maxTotalBytes: 9, + extensions: ['pdf'], + ); + final Future purging = store.purge(); + await Future.delayed(Duration.zero); + // 고르는 창이 열린 동안 복사본이 생긴다(Kotlin) + final String path = await copyOf('f1', 'a.pdf', [1]); + bool purged = false; + unawaited(purging.then((_) => purged = true)); + await Future.delayed(Duration.zero); + expect(purged, isFalse); + expect(await File(path).exists(), isTrue); + + picker.complete({'outcome': 'canceled'}); + await picking; + await purging; + expect(await Directory(rootPath()).exists(), isFalse); + }); + }); + + group('복사본', () { + test('있는가 · 읽기 · 지우기 — 복사본 폴더 바로 아래 한 칸만, 지우면 그 폴더째', () async { + final String path = await copyOf('f1', '견적서.pdf', [1, 2, 3]); + final String other = await copyOf('f2', '견적서.pdf', [4]); + expect(await store.exists(path), isTrue); + expect( + await store.openRead(path).expand((List chunk) => chunk).toList(), + [1, 2, 3], + ); + + await store.delete(path); + expect(await store.exists(path), isFalse); + expect(await Directory('${rootPath()}/f1').exists(), isFalse); + expect(await store.exists(other), isTrue); + // 이미 없어도 된다 + await store.delete(path); + }); + + test('복사본 폴더 밖 · 폴더 깊숙한 곳은 있다고 보지 않고 지우지 않는다', () async { + final File outside = File('${base.path}/downloads/x/a.pdf'); + await outside.create(recursive: true); + final File deep = File('${rootPath()}/f1/deeper/a.pdf'); + await deep.create(recursive: true); + final File direct = File('${rootPath()}/a.pdf'); + await direct.create(recursive: true); + + for (final File file in [outside, deep, direct]) { + expect(await store.exists(file.path), isFalse, reason: file.path); + await store.delete(file.path); + expect(await file.exists(), isTrue, reason: file.path); + } + // ../ 로 빠져나가는 경로도 + final String escaping = '${rootPath()}/f1/../../downloads/x/a.pdf'; + expect(await store.exists(escaping), isFalse); + await store.delete(escaping); + expect(await outside.exists(), isTrue); + }); + + test('purge — 복사본 폴더를 모두 지운다(없어도 된다) · 임시 폴더를 모르면 조용히 넘어간다', () async { + await copyOf('f1', 'a.pdf', [1]); + await store.purge(); + expect(await Directory(rootPath()).exists(), isFalse); + await store.purge(); + + final PlatformPickedFileStore broken = PlatformPickedFileStore( + baseDirectory: () async => throw MissingPluginException('시험'), + ); + await broken.purge(); + expect(await broken.exists('${rootPath()}/f1/a.pdf'), isFalse); + await broken.delete('${rootPath()}/f1/a.pdf'); + }); + }); +} diff --git a/flutter/test/features/mail/attachment_uploads_test.dart b/flutter/test/features/mail/attachment_uploads_test.dart new file mode 100644 index 0000000..60abd67 --- /dev/null +++ b/flutter/test/features/mail/attachment_uploads_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/attachment_uploads.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/file_fakes.dart'; + +/// 받은 올리기 하나 — 시험이 진행을 보내고 끝낸다 +class _Call { + _Call(this.name, this.type, this.length, this.cancel, this.progress); + + final String name; + final String type; + final int length; + final CancelToken cancel; + final void Function(int sent, int total) progress; + final Completer done = Completer(); + + void succeed(String id) => done.complete( + MessageAttachment(id: id, name: name, type: type, size: length), + ); + + void fail(String code) => + done.completeError(apiFailure(RequestOptions(path: '/x'), code)); +} + +/// 첨부 올리기 차례(`AttachmentUploadQueue`) — 둘씩 · 고른 차례 · 진행 · 빼기 · 다시 올리기 · 그만두기 +void main() { + late FakePickedFiles files; + late List<_Call> calls; + late List events; + late AttachmentUploadQueue queue; + + Future upload({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }) { + final _Call call = _Call(name, type, length, cancelToken, onProgress); + calls.add(call); + unawaited( + cancelToken.whenCancel.then((DioException error) { + if (!call.done.isCompleted) call.done.completeError(error); + }), + ); + return call.done.future; + } + + setUp(() { + files = FakePickedFiles(); + calls = <_Call>[]; + events = []; + queue = AttachmentUploadQueue( + upload: upload, + files: files, + onStarted: (int key) => events.add('start $key'), + onProgress: (int key, int percent) => events.add('$key $percent%'), + onDone: (int key, MessageAttachment attachment) => + events.add('done $key ${attachment.id}'), + onFailed: (int key, ComposeUploadFailure failure) => events.add( + 'failed $key ${failure.code ?? 'copyGone'} ${failure.canRetry}', + ), + ); + }); + + Future settle() => Future.delayed(Duration.zero); + + /// 고른 파일 하나를 차례에 넣는다 — 복사본은 가짜 보관 + PickedFile add(int key, String name, [int size = 4]) { + final PickedFile file = files.copy(name, List.filled(size, key)); + queue.add(key, path: file.path!, name: name, size: size); + return file; + } + + test( + '고른 차례대로 둘까지만 올리고, 하나가 끝나면 다음 — 형식은 확장자의 정식 형식, 다 올린 소식에는 서버의 첨부', + () async { + add(1, 'a.pdf'); + add(2, '보고서.hwp'); + add(3, 'c.png'); + await settle(); + expect(events, ['start 1', 'start 2']); + expect(calls.map((_Call c) => c.name), ['a.pdf', '보고서.hwp']); + expect(calls[1].type, 'application/x-hwp'); + + calls[0].succeed('up-1'); + await settle(); + expect(events, ['start 1', 'start 2', 'done 1 up-1', 'start 3']); + expect(calls, hasLength(3)); + expect(calls[2].type, 'image/png'); + + calls[1].succeed('up-2'); + calls[2].succeed('up-3'); + await settle(); + expect(queue.busy, isFalse); + expect(events.last, 'done 3 up-3'); + }, + ); + + test('진행 — 보이는 비율이 바뀔 때만 알린다(전체를 모르면 알리지 않는다)', () async { + add(1, 'a.pdf', 200); + await settle(); + final _Call call = calls.single; + call.progress(0, 200); + call.progress(1, 200); + call.progress(100, 200); + call.progress(101, 200); + call.progress(5, 0); + call.progress(200, 200); + expect(events, ['start 1', '1 50%', '1 100%']); + }); + + test('뺀 파일 — 올리던 것을 끊고 복사본을 지운다, 그 뒤 소식은 오지 않고 다음 차례가 시작한다', () async { + final PickedFile first = add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + await settle(); + final CancelToken cancel = calls.first.cancel; + + queue.remove(1); + await settle(); + expect(cancel.isCancelled, isTrue); + expect(files.deleted, [first.path!]); + expect(queue.hasCopy(1), isFalse); + expect(events, ['start 1', 'start 2', 'start 3']); + expect(calls.map((_Call c) => c.name), ['a.pdf', 'b.pdf', 'c.pdf']); + }); + + test('여럿을 뺄 때는 다 뺀 뒤에 다음 차례 — 뺄 파일이 그사이 시작하지 않는다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + add(4, 'd.pdf'); + await settle(); + queue.removeAll([1, 2, 3]); + await settle(); + expect(events, ['start 1', 'start 2', 'start 4']); + expect(calls.map((_Call c) => c.name), ['a.pdf', 'b.pdf', 'd.pdf']); + }); + + test('실패 — 형식 · 크기 거절은 다시 할 수 없고, 연결 실패는 복사본으로 다시 올린다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + await settle(); + calls[0].fail('FILE_002'); + calls[1].fail(kFallbackCode); + await settle(); + expect( + events, + containsAllInOrder([ + 'failed 1 FILE_002 false', + 'failed 2 SYS_001 true', + ]), + ); + expect(queue.busy, isFalse); + + // [다시 시도] — 차례의 끝에 넣고 같은 복사본을 올린다 + expect(queue.requeue(2), isTrue); + expect(queue.requeue(2), isFalse, reason: '이미 올리는 중'); + await settle(); + expect(calls.last.name, 'b.pdf'); + calls.last.succeed('up-9'); + await settle(); + expect(events.last, 'done 2 up-9'); + }); + + test('복사본이 없어졌으면 서버에 묻지 않고 "다시 골라야 함"', () async { + // 기기가 임시 폴더를 비웠다 — 차례가 오기 전에 복사본이 없어졌다 + final PickedFile file = files.copy('a.pdf', [1]); + files.copies.remove(file.path); + queue.add(1, path: file.path!, name: 'a.pdf', size: 1); + await settle(); + expect(calls, isEmpty); + expect(events, ['start 1', 'failed 1 copyGone false']); + }); + + test('그만두기 — 올리던 것을 모두 끊고 복사본을 모두 지운다, 그 뒤 넣는 파일은 복사본만 지운다', () async { + add(1, 'a.pdf'); + add(2, 'b.pdf'); + add(3, 'c.pdf'); + await settle(); + queue.close(); + await settle(); + expect(calls.every((_Call c) => c.cancel.isCancelled), isTrue); + expect(files.copies, isEmpty); + expect(files.deleted, hasLength(3)); + expect(events, ['start 1', 'start 2']); + + add(4, 'd.pdf'); + await settle(); + expect(files.copies, isEmpty); + expect(events, hasLength(2)); + expect(queue.requeue(1), isFalse); + }); +} diff --git a/flutter/test/features/mail/compose_attachments_test.dart b/flutter/test/features/mail/compose_attachments_test.dart new file mode 100644 index 0000000..77098d2 --- /dev/null +++ b/flutter/test/features/mail/compose_attachments_test.dart @@ -0,0 +1,361 @@ +import 'dart:io'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const int _mb = 1024 * 1024; + +/// 첨부의 규칙 — 형식 표(백엔드 짝) · 고른 파일 사전 검사 차례 · 한도 읽기 · 넣지 못한 알림 글 · 줄의 셈 +void main() { + group('형식 표 (짝: 백엔드 attachment.policy.ts)', () { + test('확장자 → 정식 형식이 백엔드 ATTACHMENT_TYPES 의 type 과 같다 · 개수 한도도 같다', () { + // flutter/ 에서 돈다(flutter test) — 모노레포의 백엔드 파일을 읽어 맞춰 본다 + final File policy = File( + '../backend/src/modules/mail/attachment.policy.ts', + ); + expect( + policy.existsSync(), + isTrue, + reason: '백엔드 첨부 정책 파일이 없다 — 짝을 맞춰 볼 수 없다', + ); + final String source = policy.readAsStringSync(); + final int start = source.indexOf('const ATTACHMENT_TYPES'); + final int end = source.indexOf(']);', start); + expect(start, isNot(-1)); + final Map backend = { + for (final RegExpMatch match in RegExp( + r"\[\s*'\.([a-z0-9]+)',\s*\{\s*type:\s*'([^']+)'", + ).allMatches(source.substring(start, end))) + match.group(1)!: match.group(2)!, + }; + expect(backend, hasLength(kMailAttachmentTypes.length)); + expect(backend, kMailAttachmentTypes); + expect( + RegExp( + r'MAX_ATTACHMENTS_PER_MESSAGE = (\d+);', + ).firstMatch(source)?.group(1), + '$kMailMaxAttachments', + ); + }); + + test('올릴 때의 형식 — 확장자는 대소문자를 가리지 않는다 · HEIC · 확장자 없음은 허용하지 않는다', () { + expect(attachmentUploadTypeOf('계약.PDF'), 'application/pdf'); + expect(attachmentUploadTypeOf('보고서.hwp'), 'application/x-hwp'); + expect(attachmentUploadTypeOf('보고서.HwPx'), 'application/hwp+zip'); + expect(attachmentUploadTypeOf('사진.jpeg'), 'image/jpeg'); + expect(attachmentUploadTypeOf('묶음.tar.zip'), 'application/zip'); + expect(attachmentUploadTypeOf('IMG_0001.HEIC'), isNull); + expect(attachmentUploadTypeOf('설치.exe'), isNull); + expect(attachmentUploadTypeOf('이름만'), isNull); + expect(isImageAttachment('a.WEBP'), isTrue); + expect(isImageAttachment('a.pdf'), isFalse); + expect(isImageAttachment('a.heic'), isFalse); + }); + }); + + group('고른 파일 사전 검사 (웹 precheckAttachment 와 같은 차례)', () { + const AttachmentLimits limits = AttachmentLimits( + maxBytes: 20 * _mb, + totalMaxBytes: 25 * _mb, + ); + PickedFile ok(String name, int size) => PickedFile( + name: name, + status: PickedFileStatus.ok, + size: size, + path: '/c/uploads/x/$name', + ); + AttachmentRejection? check( + PickedFile file, { + int count = 0, + int bytes = 0, + }) => precheckPickedFile( + file, + attachedCount: count, + attachedBytes: bytes, + limits: limits, + ); + + test('통과', () { + expect(check(ok('견적서.pdf', 5 * _mb), count: 19, bytes: 20 * _mb), isNull); + }); + + test('개수가 먼저 — 허용 밖 형식이어도 20개를 넘으면 개수, 가져오지 않은 파일도 개수', () { + expect(check(ok('a.pdf', 1), count: 20), AttachmentRejection.tooMany); + expect(check(ok('a.heic', 1), count: 20), AttachmentRejection.tooMany); + expect( + check( + const PickedFile(name: 'a.heic', status: PickedFileStatus.skipped), + ), + AttachmentRejection.tooMany, + ); + }); + + test('형식 다음 크기 — 큰 HEIC 는 형식, 큰 PDF 는 크기(복사하다 멈춘 것 · 알려 준 크기 모두)', () { + expect( + check( + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.tooLarge, + ), + ), + AttachmentRejection.notAllowed, + ); + expect( + check( + const PickedFile(name: 'a.pdf', status: PickedFileStatus.tooLarge), + ), + AttachmentRejection.tooLarge, + ); + expect(check(ok('a.pdf', 20 * _mb + 1)), AttachmentRejection.tooLarge); + expect(check(ok('a.pdf', 20 * _mb)), isNull); + }); + + test('읽지 못한 파일은 형식 · 크기 다음, 합계는 마지막', () { + expect( + check(const PickedFile(name: 'a.exe', status: PickedFileStatus.failed)), + AttachmentRejection.notAllowed, + ); + expect( + check(const PickedFile(name: 'a.pdf', status: PickedFileStatus.failed)), + AttachmentRejection.unreadable, + ); + expect( + check(ok('a.pdf', 6 * _mb), bytes: 20 * _mb), + AttachmentRejection.overTotal, + ); + expect(check(ok('a.pdf', 5 * _mb), bytes: 20 * _mb), isNull); + }); + + test('기기가 같은 차례로 미리 거른 까닭(형식 · 합계)은 그 까닭으로 옮긴다 — 차례가 앞선 까닭이 이긴다', () { + // 기기가 형식으로 걸렀다 — 이름이 허용 형식처럼 보여도 기기의 판정을 따른다(넣지 않는다) + expect( + check( + const PickedFile( + name: 'a.pdf', + status: PickedFileStatus.notAllowed, + size: 1, + ), + ), + AttachmentRejection.notAllowed, + ); + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 6 * _mb, + ), + ), + AttachmentRejection.overTotal, + ); + // 개수가 먼저 · 파일당이 합계보다 먼저(웹과 같은 차례) + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 6 * _mb, + ), + count: 20, + ), + AttachmentRejection.tooMany, + ); + expect( + check( + const PickedFile( + name: '도면.pdf', + status: PickedFileStatus.overTotal, + size: 21 * _mb, + ), + ), + AttachmentRejection.tooLarge, + ); + }); + + test('이미 붙은 첨부(전달 · 저장본)는 서버 합계 한도로 잰다', () { + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 6 * _mb, + attachedCount: 0, + attachedBytes: 5 * _mb, + totalMaxBytes: 10 * _mb, + ), + 'FILE_002', + ); + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 5 * _mb, + attachedCount: 0, + attachedBytes: 5 * _mb, + totalMaxBytes: 10 * _mb, + ), + isNull, + ); + }); + }); + + group('첨부 한도 (GET /api/mail-quota)', () { + test('파일당 · 합계만 읽는다 — 없거나 0 이하면 null(대체값을 쓴다)', () { + expect( + AttachmentLimits.tryParse({ + 'usedBytes': 1, + 'limitBytes': null, + 'attachmentMaxBytes': 30 * _mb, + 'attachmentTotalMaxBytes': 40.0 * _mb, + }), + const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb), + ); + expect( + AttachmentLimits.tryParse({'attachmentMaxBytes': 1}), + isNull, + ); + expect( + AttachmentLimits.tryParse({ + 'attachmentMaxBytes': 0, + 'attachmentTotalMaxBytes': 1, + }), + isNull, + ); + expect(AttachmentLimits.tryParse('x'), isNull); + expect( + kFallbackAttachmentLimits, + const AttachmentLimits(maxBytes: 20 * _mb, totalMaxBytes: 25 * _mb), + ); + }); + }); + + group('글', () { + test('한도 글 — 웹 formatMegabytes 와 같다(정수면 그대로, 아니면 소수 한 자리)', () { + expect(formatMegabytes(20 * _mb), '20MB'); + expect(formatMegabytes(25 * _mb), '25MB'); + expect(formatMegabytes(20 * _mb + _mb ~/ 2), '20.5MB'); + expect(formatMegabytes(_mb ~/ 2), '0.5MB'); + expect(formatMegabytes(20 * _mb - 1), '20MB'); + expect( + MailComposeTexts.attachmentLimits( + formatMegabytes(20 * _mb), + formatMegabytes(25 * _mb), + ), + '파일당 최대 20MB · 합계 25MB', + ); + }); + + test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () { + expect(topicParticle('사진'), '은'); + expect(topicParticle('보고서'), '는'); + expect(topicParticle('외 2개'), '는'); + expect(topicParticle('견적서.pdf'), '은(는)'); + expect(topicParticle(''), '은(는)'); + }); + + test('넣지 못한 알림 — 까닭마다 한 문장, 처음 나온 차례대로 줄을 바꿔 잇는다', () { + const AttachmentLimits limits = AttachmentLimits( + maxBytes: 20 * _mb, + totalMaxBytes: 25 * _mb, + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.overTotal: ['현장사진.jpg'], + }, limits), + '첨부를 모두 합치면 25MB를 넘어 ‘현장사진.jpg’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.overTotal: ['a.jpg', 'b.jpg', 'c.jpg'], + }, limits), + '첨부를 모두 합치면 25MB를 넘어 ‘a.jpg’ 외 2개는 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ); + expect( + pickRejectionNotice(>{ + AttachmentRejection.notAllowed: ['IMG_0001.HEIC', 'b.exe'], + AttachmentRejection.tooLarge: ['영상.mp4'], + AttachmentRejection.tooMany: ['z.pdf'], + AttachmentRejection.unreadable: ['드라이브.pdf'], + }, const AttachmentLimits(maxBytes: 30 * _mb, totalMaxBytes: 40 * _mb)), + [ + ErrorLexicon.getMessage('FILE_001'), + '30MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.', + ErrorLexicon.getMessage('MAIL_006'), + '파일을 가져오지 못했습니다. 다시 골라 주세요.', + ].join('\n'), + ); + }); + }); + + group('첨부 줄', () { + const ComposeAttachment carried = ComposeAttachment( + key: 1, + id: 'a1', + name: 'a.pdf', + size: 3, + ); + test('보낼 수 있는가 · 올리는 중인가 · 한도에 세는가', () { + final ComposeAttachment picked = carried.withUpload( + ComposeUploadStatus.waiting, + ); + expect(carried.sendable, isTrue); + expect(picked.id, ''); + expect(picked.sendable, isFalse); + expect(picked.uploading, isTrue); + expect(picked.countsTowardLimits, isTrue); + + final ComposeAttachment done = picked.withUpload( + ComposeUploadStatus.done, + id: 'up-1', + progress: 100, + ); + expect(done.sendable, isTrue); + expect(done.uploading, isFalse); + + final ComposeAttachment retryable = picked.withUpload( + ComposeUploadStatus.failed, + failure: ComposeUploadFailure.of('SYS_001'), + ); + expect(retryable.failure?.canRetry, isTrue); + expect(retryable.countsTowardLimits, isTrue); + expect(retryable.sendable, isFalse); + for (final String code in ['FILE_001', 'FILE_002', 'VAL_001']) { + expect(ComposeUploadFailure.of(code).canRetry, isFalse, reason: code); + } + expect( + picked + .withUpload( + ComposeUploadStatus.failed, + failure: ComposeUploadFailure.copyGone, + ) + .countsTowardLimits, + isFalse, + ); + expect( + carried + .copyWith(problem: ComposeAttachmentProblem.missing) + .countsTowardLimits, + isFalse, + ); + // 다시 올릴 수 없어 "찾지 못함"이 된 고른 파일 — 보내지도 세지도 않는다(빼기 전까지 막는다) + final ComposeAttachment missing = done.copyWith( + problem: ComposeAttachmentProblem.missing, + ); + expect(missing.sendable, isFalse); + expect(missing.countsTowardLimits, isFalse); + // 다 올리면 서버가 정리한 이름 · 크기로 + final ComposeAttachment renamed = picked.withUpload( + ComposeUploadStatus.done, + id: 'up-2', + name: '줄인 이름.pdf', + size: 9, + ); + expect((renamed.name, renamed.size), ('줄인 이름.pdf', 9)); + }); + }); +} diff --git a/flutter/test/features/mail/compose_repository_test.dart b/flutter/test/features/mail/compose_repository_test.dart index c269a0e..ac5eb97 100644 --- a/flutter/test/features/mail/compose_repository_test.dart +++ b/flutter/test/features/mail/compose_repository_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:arasystem_app/core/api/api_paths.dart'; @@ -5,6 +7,7 @@ import 'package:arasystem_app/core/people/person_address.dart'; import 'package:arasystem_app/core/people/recipient_suggestion.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; import 'package:dio/dio.dart'; @@ -387,4 +390,202 @@ void main() { ); expect(h.notifier.shownCodes, isEmpty); }); + + group('첨부 올리기 · 첨부 한도 (계약 2절)', () { + Map created( + String id, + String name, + String type, + int size, + ) => { + 'success': true, + 'data': attachmentJson(id, name: name, type: type, size: size), + }; + + test( + 'POST /mail-attachments — multipart file 하나(보이는 이름 · 확장자의 정식 형식 · 길이), 보내기 5분 · 받기 60초 · 알리지 않는다 · 보낸 진행', + () async { + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse( + 201, + created('att-1', '견적서 (최종).hwp', 'application/x-hwp', 5), + ), + ); + final List<(int, int)> progress = <(int, int)>[]; + + final MessageAttachment uploaded = await repository.uploadAttachment( + name: '견적서 (최종).hwp', + type: 'application/x-hwp', + length: 5, + read: () => Stream>.fromIterable(>[ + [0x41, 0x42], + [0x43, 0x44, 0x45], + ]), + cancelToken: CancelToken(), + onProgress: (int sent, int total) => progress.add((sent, total)), + ); + + expect(uploaded.id, 'att-1'); + expect(uploaded.size, 5); + final RequestOptions post = h.server.requests.single; + expect(post.sendTimeout, kAttachmentUploadSendTimeout); + expect(post.receiveTimeout, kAttachmentUploadReceiveTimeout); + expect(bearerTokenOf(post), 'access-1'); + final MapEntry file = + (post.data! as FormData).files.single; + expect(file.key, kAttachmentFileField); + expect(file.value.filename, '견적서 (최종).hwp'); + expect(file.value.contentType.toString(), 'application/x-hwp'); + expect(file.value.length, 5); + // 본문 — 한글 이름은 UTF-8 그대로(백엔드 multer `defParamCharset: utf8`), 내용은 다시 읽은 바이트 + final String body = utf8.decode(h.server.requestBodies.single.body); + expect( + body, + contains( + 'content-disposition: form-data; name="file"; filename="견적서 (최종).hwp"\r\n' + 'content-type: application/x-hwp\r\n\r\nABCDE\r\n', + ), + ); + expect(progress, isNotEmpty); + expect(progress.last.$1, progress.last.$2); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test( + '실패는 알리지 않는다(화면이 그 줄에 보인다) · 취소하면 cancel · 모양이 틀린 응답은 SYS_001', + () async { + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse.fail(400, 'FILE_001'), + ); + Future upload(CancelToken cancel) => + repository.uploadAttachment( + name: 'a.pdf', + type: 'application/pdf', + length: 1, + read: () => Stream>.value([1]), + cancelToken: cancel, + onProgress: (int sent, int total) {}, + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'FILE_001', + ); + expect(h.notifier.shownCodes, isEmpty); + + final Completer gate = Completer(); + h.server.on('POST', ComposeApiPaths.mailAttachments, (_) async { + await gate.future; + return FakeResponse( + 201, + created('att-2', 'a.pdf', 'application/pdf', 1), + ); + }); + final CancelToken cancel = CancelToken(); + final Future canceled = errorOf(upload(cancel)); + cancel.cancel(); + final Object error = await canceled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => const FakeResponse(201, { + 'success': true, + 'data': {'id': 'x'}, + }), + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'SYS_001', + ); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 파일을 처음부터 다시 보낸다(FormData 복제)', () async { + int next = 2; + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? FakeResponse(201, created('att-3', 'a.png', 'image/png', 3)) + : FakeResponse.fail(401, 'AUTH_001'), + ); + int reads = 0; + final MessageAttachment uploaded = await repository.uploadAttachment( + name: 'a.png', + type: 'image/png', + length: 3, + read: () { + reads++; + return Stream>.value([7, 8, 9]); + }, + cancelToken: CancelToken(), + onProgress: (int sent, int total) {}, + ); + expect(uploaded.id, 'att-3'); + final List<({String path, List body})> bodies = h + .server + .requestBodies + .where( + (({String path, List body}) b) => + b.path == ComposeApiPaths.mailAttachments, + ) + .toList(); + expect(bodies, hasLength(2)); + expect(bodies[1].body, bodies[0].body); + expect(reads, 2); + }); + + test( + '첨부 한도 — GET /mail-quota 의 파일당 · 합계, 알리지 않는다 · 모양이 틀리면 SYS_001', + () async { + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({ + 'usedBytes': 10, + 'limitBytes': null, + 'attachmentMaxBytes': 30, + 'attachmentTotalMaxBytes': 40, + }), + ); + expect( + await repository.getAttachmentLimits(), + const AttachmentLimits(maxBytes: 30, totalMaxBytes: 40), + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({'usedBytes': 10}), + ); + expect( + apiErrorOf(await errorOf(repository.getAttachmentLimits())).code, + 'SYS_001', + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf(repository.getAttachmentLimits()); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + }); } diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart index fd97654..9ef1055 100644 --- a/flutter/test/features/mail/compose_rules_test.dart +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -4,6 +4,7 @@ import 'package:arasystem_app/core/people/person_address.dart'; import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart index 915bb8d..46612e0 100644 --- a/flutter/test/features/mail/message_compose_page_test.dart +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -1,10 +1,15 @@ import 'dart:async'; +import 'dart:io'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/people/people_texts.dart'; import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; import 'package:arasystem_app/features/mail/domain/compose_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; @@ -17,9 +22,11 @@ import 'package:arasystem_app/features/mail/presentation/pages/message_reader_pa import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -110,9 +117,28 @@ void main() { await tester.pump(); } + /// 마지막 고르기에 넘긴 한도 — 남은 개수 · 파일당 · 남은 합계, 허용 확장자는 늘 형식 표 그대로 + void expectPick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + }) { + final ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + pick = h.picked.picks.last; + expect(pick.maxBytes, maxBytes); + expect(pick.maxCount, maxCount); + expect(pick.maxTotalBytes, maxTotalBytes); + expect(pick.extensions, unorderedEquals(kMailAttachmentTypes.keys)); + } + group('열기', () { testWidgets( - '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹을 미리 받는다 · [파일 첨부]는 준비 중', + '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹 · 첨부 한도를 미리 받는다 · [파일 첨부]는 고르는 창(닫으면 그대로)', (WidgetTester tester) async { await start(tester); await open(tester, MailRoutes.compose); @@ -130,8 +156,18 @@ void main() { findsNothing, ); + expect(h.server.received('GET', '/mail-quota'), hasLength(1)); + + // 고르는 창 — 남은 자리 · 파일당 · 남은 합계 · 허용 확장자를 넘긴다. 고르지 않고 닫으면 아무것도 없다 await tapTooltip(tester, MailComposeTexts.attach); - expect(snack(MailComposeTexts.attachNotReady), findsOneWidget); + expect(h.picked.picks, hasLength(1)); + expectPick( + maxBytes: 20 * 1024 * 1024, + maxCount: 20, + maxTotalBytes: 25 * 1024 * 1024, + ); + expect(find.byType(SnackBar), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(0)), findsNothing); await tapTooltip(tester, MailComposeTexts.expandCopies); expect(field(RecipientFieldKind.cc), findsOneWidget); @@ -1110,4 +1146,847 @@ void main() { expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1)); }); }); + + group('첨부 고르기 · 올리기 (2차)', () { + const int mb = 1024 * 1024; + + /// 고른 파일 — 크기만 정하고 복사본은 작게 둔다(가짜 서버는 본문을 읽지 않는다) + PickedFile picked(String name, {int size = 4}) { + final PickedFile copy = h.picked.copy(name, [1, 2, 3, 4]); + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: size, + path: copy.path, + ); + } + + Future attach(WidgetTester tester, List files) async { + h.picked.willPick(files); + await tapTooltip(tester, MailComposeTexts.attach); + } + + /// 다음 올리기 하나의 응답을 붙잡는다 + Completer holdUpload() { + final Completer gate = Completer(); + c.uploadGates.add(gate); + return gate; + } + + List uploadedNames() => [ + for (final ({String name, String type, int length}) upload in c.uploads) + upload.name, + ]; + + Finder inAttachments(Finder finder) => find.descendant( + of: find.byType(ComposeAttachmentsSection), + matching: finder, + ); + + testWidgets( + '[파일 첨부] — 고른 파일을 둘씩 올린다: "올리는 중 N%" → 크기, 첨부 머리에 서버 한도 · 보이는 이름 · 확장자의 정식 형식', + (WidgetTester tester) async { + c.quota = quotaJson(maxBytes: 30 * mb, totalMaxBytes: 40 * mb); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer first = holdUpload(); + final Completer second = holdUpload(); + final Completer third = holdUpload(); + await attach(tester, [ + picked('견적서.pdf'), + picked('현장사진.jpg'), + picked('보고서.hwp'), + ]); + + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 30 * mb, maxCount: 20, maxTotalBytes: 40 * mb); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(find.text('파일당 최대 30MB · 합계 40MB'), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNWidgets(3)); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('견적서.pdf')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel(MailComposeTexts.uploading('현장사진.jpg')), + findsOneWidget, + ); + // 둘까지만 서버에 보낸다 — 셋째는 기다린다 + expect(uploadedNames(), ['견적서.pdf', '현장사진.jpg']); + expect( + c.uploads.map((({String name, String type, int length}) u) => u.type), + ['application/pdf', 'image/jpeg'], + ); + + first.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames().last, '보고서.hwp'); + expect(c.uploads.last.type, 'application/x-hwp'); + expect( + find.byTooltip(MailComposeTexts.remove('견적서.pdf')), + findsOneWidget, + ); + expect(find.text(formatFileSize(4)), findsOneWidget); + + second.complete(); + third.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNothing); + expect(find.text(formatFileSize(4)), findsNWidgets(3)); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + testWidgets( + '올리는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 임시 저장은 다 올린 첨부만 싣고, 다 올리면 보낸다(닫히면 복사본을 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = holdUpload(); + await attach(tester, [picked('견적서.pdf')]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.attachmentsUploading), findsOneWidget); + expect(c.sends, isEmpty); + + // 쓰는 사이의 저장 — 올리는 중인 첨부는 싣지 않는다 + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(c.saves.single.body['attachments'], isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + expect(c.saves, hasLength(2)); + expect(c.saves.last.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'saved-2-up-1', 'name': '견적서.pdf'}, + ]); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.copies, isEmpty); + }, + ); + + testWidgets( + '사전 검사 — 형식(HEIC) · 크기 · 읽지 못함 · 개수는 넣지 않고 알림 하나로 까닭을 모은다(그 복사본은 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + final PickedFile heic = picked('IMG_0001.HEIC'); + await attach(tester, [ + heic, + const PickedFile( + name: '큰자료.pdf', + status: PickedFileStatus.tooLarge, + size: 900 * mb, + ), + picked('견적서.pdf'), + const PickedFile(name: '드라이브.pdf', status: PickedFileStatus.failed), + const PickedFile(name: 'z.pdf', status: PickedFileStatus.skipped), + ]); + + expect(find.text(MailComposeTexts.attachmentsTitle(1)), findsOneWidget); + expect(find.text('IMG_0001.HEIC'), findsNothing); + expect(find.text('큰자료.pdf'), findsNothing); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '20MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.', + '파일을 가져오지 못했습니다. 다시 골라 주세요.', + ErrorLexicon.getMessage('MAIL_006'), + ].join('\n'), + ), + findsOneWidget, + ); + expect(h.picked.deleted, [heic.path!]); + expect(uploadedNames(), ['견적서.pdf']); + }, + ); + + testWidgets('합계 한도 — 넘게 만드는 파일만 넣지 않는다(이름 + 은(는)) · 전달하는 원본의 합계도 서버 한도로 잰다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(totalMaxBytes: mb); + h.mail.put( + kInboxId, + messageJson('f3', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f3')); + // 둘째 원본은 합계(1MB)를 넘겨 싣지 않는다 + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await attach(tester, [ + picked('a.pdf', size: 500 * 1024), + picked('b.pdf', size: 300 * 1024), + ]); + expect(find.text('a.pdf'), findsNothing); + expect(find.text('b.pdf'), findsOneWidget); + expect( + snack( + '첨부를 모두 합치면 1MB를 넘어 ‘a.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ), + findsOneWidget, + ); + }); + + testWidgets('첨부 한도가 늦으면 2초까지만 기다리고 대체값으로 연다 — 받으면 한도 글이 바뀐다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(maxBytes: 5 * mb, totalMaxBytes: mb); + final Completer gate = Completer(); + c.quotaGate = gate; + h.mail.put( + kInboxId, + messageJson('f5', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + unawaited(h.router.push(MailRoutes.composeForward('f5'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + // 대체값(합계 25MB)으로 쟀다 — 둘 다 싣는다 + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsNothing); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('파일당 최대 5MB · 합계 1MB'), findsOneWidget); + }); + + testWidgets('이미 20개면 고르는 창을 열지 않고 알린다 · 남은 자리만큼만 가져오게 한다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + // 남은 자리 2 · 남은 합계 25MB − 18 × 10바이트 + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: 25 * mb - 180); + expect(find.text(MailComposeTexts.attachmentsTitle(20)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.attach); + expect(h.picked.picks, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_006')), findsOneWidget); + }); + + testWidgets( + '✕ 는 올리기 취소 — 줄을 빼고 복사본을 지우고 기다리던 파일이 이어 올라간다 · 한도를 받지 못하면 20 · 25MB', + (WidgetTester tester) async { + c.quota = null; + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + holdUpload(); + final Completer third = holdUpload(); + final PickedFile first = picked('a.pdf'); + await attach(tester, [ + first, + picked('b.pdf'), + picked('c.pdf'), + ]); + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 20 * mb, maxCount: 20, maxTotalBytes: 25 * mb); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + expect(uploadedNames(), ['a.pdf', 'b.pdf']); + + await tapTooltip(tester, MailComposeTexts.cancelUpload('a.pdf')); + expect(find.text('a.pdf'), findsNothing); + expect(h.picked.deleted, [first.path!]); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'c.pdf']); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + third.complete(); + await tester.pumpAndSettle(); + expect( + find.byTooltip(MailComposeTexts.remove('c.pdf')), + findsOneWidget, + ); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('b.pdf')), + findsOneWidget, + ); + }, + ); + + testWidgets( + '올리지 못하면 "올리지 못했습니다." · 사전 문구 · [다시 시도](복사본으로 다시) — 형식 거절은 빼기만, 알림은 띄우지 않는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + c.uploadFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(400, 'FILE_001'), + ]); + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + + expect(find.text(MailComposeTexts.uploadFailed), findsNWidgets(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_001'))), + findsOneWidget, + ); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('FILE_001'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsOneWidget); + expect( + findCopy(MailComposeTexts.retryUploadLabel('a.pdf')), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'a.pdf']); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect( + find.byTooltip(MailComposeTexts.remove('a.pdf')), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + }, + ); + + testWidgets( + '보내기가 첨부를 찾지 못하면(MAIL_004) 고른 파일은 복사본으로 다시 올리고, 전달 원본은 "찾지 못함" — 빼면 보낸다', + (WidgetTester tester) async { + h.mail.put( + kInboxId, + messageJson('f4', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '원본.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f4')); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + expect( + find.byTooltip(MailComposeTexts.remove('추가.pdf')), + findsOneWidget, + ); + + await tapTooltip(tester, MailComposeTexts.remove('원본.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }, + ); + + testWidgets( + '올리는 중에 나가면 따로 묻는다 — [계속 쓰기]면 그대로, [나가기]면 올리던 파일을 빼고(복사본을 지운다) 닫는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(findCopy(MailComposeTexts.uploadLeaveMessage), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + }, + ); + + testWidgets('묻는 사이 다 올리면 창을 닫고 다 올린 첨부를 저장한 뒤 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.body['attachments'], [ + {'id': 'up-1', 'name': 'a.pdf'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('세션이 끝나면 묻지 않고 올리던 것을 그만두고 복사본을 지운다 — 직원이 바뀌면 복사본 폴더도 비운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + final int purges = h.picked.purgeCount; + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(h.picked.copies, isEmpty); + expect(h.picked.purgeCount, greaterThan(purges)); + }); + + testWidgets('고르는 · 가져오는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 다 가져오면 그 파일과 함께 보낸다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + h.picked.pickGate = gate; + h.picked.willPick([picked('견적서.pdf')]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(snack(MailComposeTexts.attachmentsPicking), findsOneWidget); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['견적서.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + }); + + testWidgets('고른 결과가 화면을 떠난 뒤에 오면 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, [file.path!]); + expect(c.uploads, isEmpty); + }); + + testWidgets('고르는 사이 세션이 끝나면 고른 결과를 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, contains(file.path)); + expect(c.uploads, isEmpty); + }); + + testWidgets( + '기기가 미리 거른 파일(형식 · 합계)은 개수 · 합계에 들지 않는다 — 18개에 HEIC · 견적서 · 큰도면 · 도면이면 두 PDF 를 넣는다(웹과 같다)', + (WidgetTester tester) async { + c.quota = quotaJson(totalMaxBytes: mb); + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + // 기기(MainActivity.kt 의 copyPicked)가 같은 차례로 거른 결과 — HEIC 는 읽지 않았고, 큰도면은 합계를 넘겨 넣지 않았다 + await attach(tester, [ + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.notAllowed, + size: 3, + ), + picked('견적서.pdf', size: 600 * 1024), + const PickedFile( + name: '큰도면.pdf', + status: PickedFileStatus.overTotal, + size: 600 * 1024, + ), + picked('도면.pdf', size: 300 * 1024), + ]); + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: mb - 180); + expect( + find.text(MailComposeTexts.attachmentsTitle(20)), + findsOneWidget, + ); + expect(uploadedNames(), ['견적서.pdf', '도면.pdf']); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '첨부를 모두 합치면 1MB를 넘어 ‘큰도면.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ].join('\n'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + '다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다 — 255자 넘는 이름도 저장 · 발송이 거절되지 않게', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final String longName = '${'가' * 300}.pdf'; + final String serverName = serverFileName(longName); + c.uploadedSize = 6; + await attach(tester, [picked(longName)]); + + // 올릴 때는 고른 이름 그대로 보내고(서버가 줄인다), 다 올리면 서버의 이름으로 + expect(c.uploads.single.name, longName); + expect(serverName.runes.length, 255); + expect(serverName, endsWith('.pdf')); + expect(find.text(serverName), findsOneWidget); + expect(find.text(longName), findsNothing); + expect(find.text(formatFileSize(6)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': serverName}, + ]); + }, + ); + + testWidgets('다시 올린 뒤 또 MAIL_004 면 "찾지 못함" — 빼기 전까지 보내기 · 저장을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_004'), + FakeResponse.fail(400, 'MAIL_004'), + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + // 이미 한 번 다시 올렸다 — 또 올리지 않고 빼기 전까지 막는다 + expect(uploadedNames(), hasLength(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('추가.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(3)); + expect(c.sends.last.body['attachments'], isEmpty); + }); + + testWidgets('MAIL_004 뒤 복사본이 없어져 다시 올리지 못하면 빼기 전까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + // 기기가 임시 폴더를 비웠다 + h.picked.copies.clear(); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(1)); + expect( + inAttachments(findCopy(MailComposeTexts.uploadCopyGone)), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + }); + + testWidgets('MAIL_004 뒤 다시 올리기가 실패하면 [다시 시도]로 다 올릴 때까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(2)); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), hasLength(3)); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }); + + testWidgets('떠난 뒤 기다린 저장이 고른 파일을 찾지 못하면(MAIL_004) 그 파일 없이 보내지 않고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves, hasLength(1)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + // 보내는 중 — 묻지 않고 떠난다(올리기 차례 · 복사본이 닫혀 다시 올릴 수 없다) + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + expect(c.saves, hasLength(1)); + expect(uploadedNames(), hasLength(1)); + }); + + testWidgets('묻는 사이 마지막 올리기가 실패하면 창을 닫지 않고 답을 기다린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(find.byType(MessageComposePage), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + }); + + testWidgets('답장 · 첨부 없는 저장본은 첨부 한도를 기다리지 않고 연다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '안부', isRead: true), + ); + c.putDraft('d2', subject: '쓰던 메일'); + await start(tester); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeReply('r1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), 'Re: 안부'); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeDraft('d2'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), '쓰던 메일'); + }); + + testWidgets( + '기기가 답하지 않는 고르기는 시한(10분) 뒤 풀린다 — 알리고 [보내기] · [파일 첨부]가 다시 되고 다음 고르기가 묶이지 않는다', + (WidgetTester tester) async { + // 기기 채널 쪽 차례 · 시한까지 보려고 실제 보관소를 쓴다 — 첫 고르기는 답이 오지 않는다(고른 앱이 멈춤) + final List channelCalls = []; + final Completer stuck = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, ( + MethodCall call, + ) { + channelCalls.add(call); + return channelCalls.length == 1 + ? stuck.future + : Future.value({ + 'outcome': 'canceled', + }); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, null), + ); + h.pickedStore = PlatformPickedFileStore( + baseDirectory: () async => Directory.systemTemp, + ); + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + await tester.pump(kPickTimeout); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(snack(MailComposeTexts.pickReadFailed), findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNotNull); + + // 다음 고르기는 시한으로 끝난 고르기 뒤에 묶이지 않는다 + await tapTooltip(tester, MailComposeTexts.attach); + expect(channelCalls, hasLength(2)); + expect(pickingBar, findsNothing); + + // [보내기]도 다시 된다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + }, + ); + + testWidgets('고르는 동안 위에 진행 막대 · [파일 첨부]는 막힌다 — 고르지 못하면 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + gate.complete(); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(tester.widget(attachButton).onPressed, isNotNull); + expect(find.byType(SnackBar), findsNothing); + + h.picked.results.add(const PickResult(PickOutcome.failed)); + await tapTooltip(tester, MailComposeTexts.attach); + expect(snack(MailComposeTexts.pickFailed), findsOneWidget); + }); + }); } diff --git a/flutter/test/support/compose_fakes.dart b/flutter/test/support/compose_fakes.dart index 2e048ff..caf083c 100644 --- a/flutter/test/support/compose_fakes.dart +++ b/flutter/test/support/compose_fakes.dart @@ -11,7 +11,7 @@ import 'mail_fakes.dart'; typedef SentRequest = ({Map body, String? key}); /// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 · -/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태. +/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태 · 첨부 올리기 · 첨부 한도. /// /// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와 /// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다. @@ -74,8 +74,28 @@ class FakeComposeBackend { /// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) Completer? largeFileGate; + /// 받은 첨부 올리기 차례대로 — multipart 의 이름 · 형식 · 길이 + final List<({String name, String type, int length})> uploads = + <({String name, String type, int length})>[]; + + /// 다음 올리기들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List uploadFailures = []; + + /// 올리기 응답이 차례대로 이것들이 열릴 때까지 기다린다(온 차례대로 하나씩 쓴다 — 없으면 바로 답한다) + final List> uploadGates = >[]; + + /// 첨부 한도 응답(`MailQuota`) — null 이면 받지 못한다(503 `MAIL_001`) + Map? quota = quotaJson(); + + /// 있으면 올리기 응답의 크기를 이것으로 준다(서버가 센 크기 — 없으면 보낸 길이) + int? uploadedSize; + + /// 있으면 첨부 한도 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? quotaGate; + int _nextDraft = 0; int _nextSent = 0; + int _nextUpload = 0; static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$'); static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$'); @@ -185,6 +205,18 @@ class FakeComposeBackend { 'pageInfo': {'hasNext': false, 'nextCursor': null}, }); }); + server.on('POST', ComposeApiPaths.mailAttachments, _upload); + server.on('GET', ComposeApiPaths.mailQuota, (_) async { + final Completer? gate = quotaGate; + if (gate != null) { + quotaGate = null; + await gate.future; + } + final Map? current = quota; + return current == null + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(current); + }); server.onPattern('GET', _filePath, (RequestOptions options) async { final Completer? gate = largeFileGate; if (gate != null) { @@ -246,6 +278,27 @@ class FakeComposeBackend { }); } + Future _upload(RequestOptions options) async { + final MultipartFile file = (options.data! as FormData).files.single.value; + final String name = file.filename ?? ''; + uploads.add(( + name: name, + type: file.contentType.toString(), + length: file.length, + )); + if (uploadGates.isNotEmpty) await uploadGates.removeAt(0).future; + if (uploadFailures.isNotEmpty) return uploadFailures.removeAt(0); + return FakeResponse(201, { + 'success': true, + 'data': attachmentJson( + 'up-${++_nextUpload}', + name: serverFileName(name), + type: file.contentType.toString(), + size: uploadedSize ?? file.length, + ), + }); + } + Future _send(RequestOptions options) async { final Map body = options.data! as Map; sends.add(( @@ -296,3 +349,29 @@ class FakeComposeBackend { }); } } + +/// 첨부 한도 응답(`MailQuota`) — 기본은 서버 기본값(20 · 25MB) +Map quotaJson({ + int maxBytes = 20 * 1024 * 1024, + int totalMaxBytes = 25 * 1024 * 1024, +}) => { + 'usedBytes': 0, + 'limitBytes': null, + 'attachmentMaxBytes': maxBytes, + 'attachmentTotalMaxBytes': totalMaxBytes, +}; + +/// 서버가 받은 첨부 이름을 줄이는 모양 — 백엔드 `shared/utils/file-name.ts` 의 `sanitizeFileName` 처럼 255 코드 포인트까지, 확장자는 남긴다 +String serverFileName(String name) { + const int maxLength = 255; + final List runes = name.runes.toList(); + if (runes.length <= maxLength) return name; + final int dot = name.lastIndexOf('.'); + final List extension = dot > 0 + ? name.substring(dot).runes.toList() + : []; + return String.fromCharCodes([ + ...runes.take(maxLength - extension.length), + ...extension, + ]); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart index f163c52..1e171eb 100644 --- a/flutter/test/support/fakes.dart +++ b/flutter/test/support/fakes.dart @@ -227,6 +227,14 @@ class FakeServer implements HttpClientAdapter { /// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다 DateTime? serverTime; + /// 켜면 처리기를 부르기 전에 요청 본문을 끝까지 읽어 [requestBodies] 에 둔다 — 보내는 진행(onSendProgress)이 오고, + /// multipart 본문을 바이트로 볼 수 있다(꺼 두면 본문은 `RequestOptions.data` 로만 본다) + bool readRequestBodies = false; + + /// 읽은 요청 본문 — [readRequestBodies] 를 켠 동안 받은 차례대로 + final List<({String path, List body})> requestBodies = + <({String path, List body})>[]; + /// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것 final List<({String method, RegExp path, FakeHandler handler})> _patterns = <({String method, RegExp path, FakeHandler handler})>[]; @@ -266,6 +274,11 @@ class FakeServer implements HttpClientAdapter { ) async { // 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게 requests.add(options.copyWith()); + if (readRequestBodies && requestStream != null) { + final List body = []; + await requestStream.forEach(body.addAll); + requestBodies.add((path: options.path, body: body)); + } final FakeHandler? handler = _handlerOf(options); final FakeResponse response = handler == null ? FakeResponse.fail(404, 'RES_001') diff --git a/flutter/test/support/file_fakes.dart b/flutter/test/support/file_fakes.dart index 6b3ea07..9eb9d96 100644 --- a/flutter/test/support/file_fakes.dart +++ b/flutter/test/support/file_fakes.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; /// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다) class MemoryFileStore implements DownloadedFileStore { @@ -94,3 +95,103 @@ class RecordingLinkLauncher { return result; } } + +/// 고른 파일의 가짜 — 고르기의 결과를 차례대로 준다([willPick] · [results] — 비면 고르지 않고 닫은 것). +/// 복사본은 메모리(경로마다 바이트)다 — 시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다 +class FakePickedFiles implements PickedFileStore { + /// 다음 고르기들의 결과 — 차례대로 하나씩 쓴다 + final List results = []; + + /// 받은 고르기 — 넘겨받은 한도들(남은 개수 · 파일당 · 남은 합계 · 허용 확장자) + final List< + ({int maxBytes, int maxCount, int maxTotalBytes, List extensions}) + > + picks = + < + ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + >[]; + + /// 있으면 고르기가 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) — 고르는 창이 열려 있는 동안 + Completer? pickGate; + + /// 복사본 — 경로마다 바이트 + final Map> copies = >{}; + + /// 지운 복사본 차례대로 + final List deleted = []; + int purgeCount = 0; + int _next = 0; + + /// 고른 파일 하나 — 복사본을 두고 그 [PickedFile] 을 준다 + PickedFile copy(String name, List bytes) { + final String path = '/cache/$kPickedFilesFolder/${++_next}/$name'; + copies[path] = bytes; + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: bytes.length, + path: path, + ); + } + + /// 다음 고르기에서 이 파일들을 고른다 — 기기가 미리 거른 결과(`MainActivity.kt` 의 `copyPicked`)를 그대로 적는다 + void willPick(List files) => + results.add(PickResult(PickOutcome.picked, files)); + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) async { + picks.add(( + maxBytes: maxBytes, + maxCount: maxCount, + maxTotalBytes: maxTotalBytes, + extensions: extensions, + )); + final Completer? gate = pickGate; + if (gate != null) { + pickGate = null; + await gate.future; + } + return results.isEmpty + ? const PickResult(PickOutcome.canceled) + : results.removeAt(0); + } + + @override + Future exists(String path) async => copies.containsKey(path); + + /// 조각 넷으로 흘린다 — 보내는 진행이 여러 번 온다 + @override + Stream> openRead(String path) { + final List? bytes = copies[path]; + if (bytes == null) { + return Stream>.error(const FileSystemException('없음(시험)')); + } + final int step = (bytes.length / 4).ceil().clamp(1, bytes.length + 1); + return Stream>.fromIterable(>[ + for (int start = 0; start < bytes.length; start += step) + bytes.sublist(start, (start + step).clamp(0, bytes.length)), + ]); + } + + @override + Future delete(String path) async { + deleted.add(path); + copies.remove(path); + } + + @override + Future purge() async { + purgeCount++; + copies.clear(); + } +} diff --git a/flutter/test/support/mail_harness.dart b/flutter/test/support/mail_harness.dart index dab2e99..6306e51 100644 --- a/flutter/test/support/mail_harness.dart +++ b/flutter/test/support/mail_harness.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/app.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; import 'package:arasystem_app/core/router/app_router.dart'; import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart'; @@ -27,7 +28,7 @@ const Size kPhoneSize = Size(390, 844); /// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다. /// /// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]), -/// 바깥 브라우저([links]). +/// 바깥 브라우저([links]), 고른 파일([picked] — 고르는 창 · 복사본). class MailAppHarness { MailAppHarness(); @@ -37,6 +38,10 @@ class MailAppHarness { final MemoryFileStore files = MemoryFileStore(); final RecordingFileOpener opener = RecordingFileOpener(); final RecordingLinkLauncher links = RecordingLinkLauncher(); + final FakePickedFiles picked = FakePickedFiles(); + + /// 고른 파일을 두는 곳 — 처음은 [picked](가짜). 기기 채널 쪽 규칙(시한 등)을 보는 시험은 [pump] 전에 바꿔 끼운다 + late PickedFileStore pickedStore = picked; /// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다 final List overrides = []; @@ -82,6 +87,7 @@ class MailAppHarness { downloadedFileStoreProvider.overrideWithValue(files), fileOpenerProvider.overrideWithValue(opener), mailLinkLauncherProvider.overrideWithValue(links.call), + pickedFileStoreProvider.overrideWithValue(pickedStore), ...overrides, ], child: const App(), -- 2.52.0 From 7546af024385c2fc2e8cc7091d9d4ee3b25743f9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 04:20:20 +0900 Subject: [PATCH 13/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=202=EC=B0=A8(=EC=B2=A8=EB=B6=80=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=98=AC=EB=A6=AC=EA=B8=B0)?= =?UTF-8?q?=EC=9D=98=20=EA=B5=AC=ED=98=84=ED=95=98=EB=A9=B0=20=EC=A0=95?= =?UTF-8?q?=ED=95=9C=20=EA=B2=83=EA=B3=BC=20=EC=A7=9D=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 디자인 README 「쓰기」(첨부 · 나가기 · 알려진 한계 · 실기기에서 볼 것), flutter 스킬(받은 파일 · 고른 파일 · 형식 줄, 메일 쓰기 줄, Kotlin 을 고쳤을 때의 apk 빌드, 시험 함정), 백엔드 형식 표(ATTACHMENT_TYPES)에 앱 형식 표와의 짝 주석. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 9 +++-- backend/src/modules/mail/attachment.policy.ts | 3 ++ docs/design/app/README.md | 33 ++++++++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 79b6d8e..e426177 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -26,8 +26,8 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 | | **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 | | **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` | -| 받은 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `file_names.dart`(`safeFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다 | -| **메일 쓰기 (`App1Compose` — 1차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위) | +| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) | +| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | @@ -288,6 +288,8 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser - [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상. - [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`). - [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다). + Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고, + Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다). Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. @@ -297,6 +299,9 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser `ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`). 되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`). 소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다. + 기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`). + 무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다. + 가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다. - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). - [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`). `.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다. diff --git a/backend/src/modules/mail/attachment.policy.ts b/backend/src/modules/mail/attachment.policy.ts index bc8b821..4a05fc4 100644 --- a/backend/src/modules/mail/attachment.policy.ts +++ b/backend/src/modules/mail/attachment.policy.ts @@ -51,6 +51,9 @@ interface AttachmentTypeRule { * 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다. * 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다. * 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다. + * + * **짝: flutter `lib/features/mail/domain/compose_attachments.dart` 의 `kMailAttachmentTypes`**(확장자 · 정식 형식) — 앱 시험이 이 파일을 읽어 + * 이 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다. 앱 CI 는 flutter/ 가 바뀐 커밋에서만 도니 표를 고치면 앱 쪽도 함께 고친다. */ const ATTACHMENT_TYPES: ReadonlyMap = new Map([ ['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }], diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 928695f..2b5adb4 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -120,7 +120,7 @@ - 보낸 뒤에는 쓰기를 시작한 화면(읽기 · 목록)으로 돌아가고, 그 화면 아래에 "메일을 보냈습니다. [보내기 취소]"와 남은 시간 막대. - 만들 때 지킬 것(2026-11-06 읽기 화면 리뷰): 읽기 화면의 mailto 링크로 채운 숨은참조가 있으면 숨은참조 칸을 펼쳐 보인다 — 링크가 숨은참조를 몰래 넣을 수 있다(RFC 6068 보안 고려). -- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 — 첨부 고르기 · 올리기는 2차) +- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 · 2차(첨부 고르기 · 올리기)) - 본문: 글만 쓴다. 보낼 때 서식 본문 + 텍스트를 함께 만든다(계약 2절 "앱이 만드는 모양"). 원문 인용은 글로 만든 덩이라 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다(웹에서 쓴 답장은 원문 서식이 남는다 — 서식 편집을 넣는 다음 판에서 넓힌다). 처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 내 글만으로 넘으면 본문 아래 띠(`MAIL_007`). - 서명: 따로 된 칸(점선 위 · [서명 빼기] → 그 자리에 [서명 넣기] 한 줄). 새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만 넣는다. @@ -149,13 +149,38 @@ - 나가기: 잃을 것이 없으면 바로 닫는다(예측 뒤로 가기). 있으면 쓰다 만 주소를 확정하고 저장(5초)한다 — 저장했으면 "임시보관함에 저장했습니다."로 닫고, 못 했으면 "저장하지 못한 내용이 있습니다" / "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]. 창을 연 뒤 저장이 끝나 잃을 것이 없어지면 저절로 닫고 떠난다. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다. 보내는 중에는 묻지 않고, 세션이 끝나면 창만 닫고 알리지 않는다. - - 첨부(1차): 전달하는 원본의 첨부와 저장본에 있던 첨부 · 대용량 파일을 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다 - (함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일). [파일 첨부]는 "앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요." 첨부 머리의 한도 글은 2차다. + 올리는 중인 첨부가 있으면 먼저 "올리는 중인 첨부가 있습니다" / "이 화면을 나가면 올리던 파일은 빠집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]로 묻는다 — + [나가기]면 올리던 · 기다리던 파일을 빼고 위 차례(저장 → 닫기)로 간다. 묻는 사이 모두 다 올리면 저절로 이어 가고, 올리지 못한 파일이 새로 생기면 창을 그대로 두고 답을 기다린다. + - 첨부 + - [파일 첨부]는 안드로이드 고르는 창(모든 형식 · 여러 개 · 권한 없음 — 형식으로 거르면 hwp 가 숨는다)이다. 이미 20개면 창을 열지 않고 `MAIL_006` 사전 문구를 알린다. + - 고른 파일은 앱 임시 폴더에 복사해 두고 그 복사본을 올린다 — 올리기에 실패해도 다시 고르지 않는다. 기기가 아래 사전 검사와 같은 차례로 미리 걸러 넣을 파일만 복사한다 + (`content://` 만 · 받지 않을 형식과 합계를 넘는 파일은 읽지 않고 · 파일당 한도는 + 1바이트에서 멈춘다). 판정은 앱이 다시 한다. + 가져오는 동안은 위 막대 아래에 진행 막대가 보이고 [파일 첨부]가 막히며, [보내기]는 "첨부할 파일을 가져오는 중입니다. 다 가져온 뒤 보내 주세요." + 고르기 한 번은 10분(고르는 창에 머문 시간 포함)까지 기다리고, 넘으면 "파일을 가져오지 못했습니다. 다시 골라 주세요."로 끝내 [보내기] · [파일 첨부]를 푼다 + (늦게 온 복사본은 다음 정리에 지운다 — 2026-11-07 리뷰: 끝나지 않는 클라우드 파일이 [보내기]와 다음 고르기를 묶었다). + - 사전 검사는 웹과 같은 차례다: 개수 20 `MAIL_006` → 형식 `FILE_001`(HEIC 포함) → 파일당 → 합계. 개수 · 합계에는 넣을 파일만 센다. + 한도는 쓰기 화면을 열 때 `GET /api/mail-quota` 로 받는다(못 받으면 20 · 25MB). 원본 첨부가 있는 전달 · 첨부가 있는 저장본만 그 합계를 재려고 열 때 2초까지 기다린다(웹은 기다리지 않는다). + - 넣지 못한 파일은 줄로 남기지 않고 알림 하나로 까닭을 모은다(까닭마다 한 문장 — 합계를 넘긴 파일이 여럿이면 "‘파일’ 외 N개는"). + 파일당 한도를 넘으면 "{한도}보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요."(대용량 첨부는 다음 판). + - 올리기는 고른 차례대로 둘씩(보내기 5분 · 받기 60초)이고, 줄은 진행 막대와 "올리는 중 N%"(기다리는 줄도 0%), ✕ 는 올리기 취소다. 다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다(웹과 같다). + - 실패하면 빨간 테두리 · "올리지 못했습니다." · 사전 문구 · [다시 시도]. 형식 · 크기 거절(`FILE_001` · `FILE_002` · `VAL_001`)과 기기가 지운 복사본("올리려던 파일을 찾지 못했습니다. 빼고 다시 골라 주세요.")은 + [다시 시도] 없이 빼기만 한다. 처음 올리기에 실패한 줄은 보내기를 막지 않는다(웹과 같다). + - 올리는 동안 [보내기]는 "올리는 중인 첨부가 있습니다. 다 올린 뒤 보내 주세요."(웹 `sendBlockOf` 의 차례 — 가져오는 중은 그 뒤). 임시 저장은 다 올린 첨부만 싣고, 하나를 다 올리면 바꿈으로 보고 자동 저장한다. + - `MAIL_004` 면 고른 파일은 복사본으로 한 번 다시 올린다. 다시 올릴 수 없거나 다시 올리다 실패하면 원본 · 저장본의 첨부처럼 "찾지 못함"으로 두고, + 빼거나 [다시 시도]로 다시 올리기에 성공하기 전까지 저장 · 보내기를 막는다(2026-11-07 리뷰 — 떠난 뒤 보내기에서 그 파일 없이 나갔다). + - 머리 오른쪽에 "파일당 최대 20MB · 합계 25MB"(서버 값). 그림 첨부는 그림 아이콘이다. + - 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일). + - 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다. - 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12). - **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다. - 첨부 합계 한도는 1차에서 25MB 대체값이다(`mail-quota` 는 2차). **푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다. + 고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다). + **푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다. - **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤, 서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림. + 첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지, + 20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기, + 고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분), + 실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글. ### 새 메일 알림 (푸시) - 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). -- 2.52.0 From 94da1d203142db885cdd90313e86ac0ab7b6b257 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 04:22:19 +0900 Subject: [PATCH 14/32] =?UTF-8?q?style(flutter):=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EC=9D=98=20=EB=82=A0=EA=B2=83=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EA=B8=80=EC=9E=90=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nbsp(본문 코드 둘 · 시험 하나)와 폭 없는 공백 · BOM(시험 하나)이 소스에 그대로 있었다 — 눈에 보이지 않아 고치다 지우거나 잘못 옮기기 쉽다. flutter 스킬 규칙대로 \u 이스케이프로 바꾼다. 뜻은 같다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/features/mail/domain/compose_draft_body.dart | 4 ++-- flutter/test/features/mail/compose_rules_test.dart | 2 +- flutter/test/features/mail/mail_reader_rules_test.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flutter/lib/features/mail/domain/compose_draft_body.dart b/flutter/lib/features/mail/domain/compose_draft_body.dart index 528b37f..c32e8ad 100644 --- a/flutter/lib/features/mail/domain/compose_draft_body.dart +++ b/flutter/lib/features/mail/domain/compose_draft_body.dart @@ -267,7 +267,7 @@ List? _plainParagraphLines(dom.Element paragraph) { final List lines = []; for (final dom.Node child in paragraph.nodes) { if (child is dom.Text) { - current.write(child.data.replaceAll(' ', ' ')); + current.write(child.data.replaceAll('\u00A0', ' ')); } else if (child is dom.Element && child.localName == 'br') { lines.add(current.toString()); current.clear(); @@ -381,7 +381,7 @@ ComposeQuote composeQuoteOf(dom.Element blockquote, {required bool keepHtml}) { List _cellsOf(dom.Element row) => [ for (final dom.Element cell in row.children) if (cell.localName == 'td' || cell.localName == 'th') - nodeTextOf(cell).replaceAll(' ', ' ').trim(), + nodeTextOf(cell).replaceAll('\u00A0', ' ').trim(), ]; /// 보낸 사람 머리 줄의 이름 — `이도윤 ` → "이도윤" (이름이 없으면 주소) diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart index 9ef1055..9efeba3 100644 --- a/flutter/test/features/mail/compose_rules_test.dart +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -16,7 +16,7 @@ import 'package:flutter_test/flutter_test.dart'; import '../../support/mail_fakes.dart'; -const String _nbsp = ' '; +const String _nbsp = '\u00A0'; const String _wrapper = '
'; const String _p = '

'; diff --git a/flutter/test/features/mail/mail_reader_rules_test.dart b/flutter/test/features/mail/mail_reader_rules_test.dart index 0304052..dc39a53 100644 --- a/flutter/test/features/mail/mail_reader_rules_test.dart +++ b/flutter/test/features/mail/mail_reader_rules_test.dart @@ -156,7 +156,7 @@ void main() { expect(safeFileName(r'a\b:c*d?e"fh|i.txt'), 'a_b_c_d_e_f_g_h_i.txt'); expect(safeFileName('보고\u0000서\u001F.pdf'), '보고서.pdf'); expect(safeFileName('invoice\u202Efdp.exe'), 'invoicefdp.exe'); - expect(safeFileName('​견적.xlsx'), '견적.xlsx'); + expect(safeFileName('\u200B\uFEFF견적.xlsx'), '견적.xlsx'); }); test('앞뒤 공백 · 점을 지우고, 비면 기본 이름', () { -- 2.52.0 From 88ec6433b9a55032cb6be4732e229144c3fe0c45 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 10:18:30 +0900 Subject: [PATCH 15/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?1=EC=B0=A8=20=EB=B3=B4=EB=93=9C(App1Search=20=C2=B7=20App1Searc?= =?UTF-8?q?hStates)=EB=A5=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자가 추천대로 확정한 앱 메일 검색 화면 13장이다(캔버스 v60). 강조된 미리 보기 글은 진하게(#4A5363 — 노랑 위 6.3:1) 그렸다. 화면의 글은 이어서 다듬는 화면 문구(상수)가 기준이다. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/App1Search.dc.html | 484 +++++++++++++++++++ docs/design/webmail/App1SearchStates.dc.html | 396 +++++++++++++++ docs/design/webmail/canvas.json | 9 +- 3 files changed, 886 insertions(+), 3 deletions(-) create mode 100644 docs/design/webmail/App1Search.dc.html create mode 100644 docs/design/webmail/App1SearchStates.dc.html diff --git a/docs/design/webmail/App1Search.dc.html b/docs/design/webmail/App1Search.dc.html new file mode 100644 index 0000000..7728f77 --- /dev/null +++ b/docs/design/webmail/App1Search.dc.html @@ -0,0 +1,484 @@ + + + + +앱 · 메일 검색 + + + + + + + +

+
+
+ + + +
+
+
+ +
+

찾기 전 (자판 올라옴 · 탭 막대 숨김)

+
+
+ + + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

보낸 사람, 받는 사람, 제목, 본문, 첨부 파일 이름에서 찾습니다. 여러 낱말을 넣으면 모두 들어간 메일을 찾습니다.

+
+ +
+
+ +
+

찾는 중

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 찾는 중

+
+ 찾는 중 + +
+ +
+
+ +
+

결과 · 받은편지함에서

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + +
+
+ +
+

결과 · 전체 메일함에서

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+

‘계약’ · 6통

+ + +
+
+ +
+

검색 준비 중

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 3통

+
+ +

지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.

+
+ + +
+
+ +
+

한 글자로 찾음

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘김’ · 2통

+
+ +

한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.

+
+ + +
+
+ +
+

{{ b7Title }}

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘해지 통보’ · 0통

+ +
+ +

지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.

+
+
+
+ +

‘해지 통보’에 맞는 메일이 없습니다

+

다른 말로 찾아 보세요.

+ +

준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.

+
+ +
+ +
+
+ +
+
+ + + + diff --git a/docs/design/webmail/App1SearchStates.dc.html b/docs/design/webmail/App1SearchStates.dc.html new file mode 100644 index 0000000..a3cbe52 --- /dev/null +++ b/docs/design/webmail/App1SearchStates.dc.html @@ -0,0 +1,396 @@ + + + + +앱 · 메일 검색 · 상태 + + + + + + + +
+
+
+ + + +
+
+
+ +
+

{{ b8Title }}

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+

‘정산서’

+
+ +

아직 맞는 메일을 찾지 못했습니다

+

메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.

+ + + + + + +
+ +
+
+ +
+

목록 끝 · 더 찾기

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘견적’

+
+ +
+

메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.

+ +
+
+ +
+
+ +
+

받지 못함

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+
+ +

일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.

+

문의 코드 3f1c0a2e

+ +
+ +
+
+ +
+

찾을 말이 너무 많음 (칸이 두 줄로)

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+
+ +

찾을 말이 너무 많습니다

+

검색어의 낱말은 8개까지 찾을 수 있습니다. 낱말을 줄여 주세요.

+
+ +
+
+ +
+

결과에서 연 메일 (첨부는 본문 위)

+
+
+ + + + +
+
+
+

Re: 유지보수 계약 갱신 건

+
+ +
+
+ 이도윤 + 오전 10:42 +
+ +

메일함받은편지함

+
+
+
+

첨부 파일 1개

+
    +
  • + +
    + 계약서_초안_v2.pdf + 248 KB +
    + +
  • +
+
+
도윤입니다. + +말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는 작년과 같습니다. + +계약 시작일만 확인 부탁드립니다.
+
+
+ +
+
+ +
+

읽기 화면에서 지운 뒤

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

{{ count13 }}

+ + + +
+ 메일 1통을 휴지통으로 옮겼습니다. + +
+
+
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index e91d906..a639d08 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -54,9 +54,11 @@ "App1MailList.dc.html": { "x": 988, "y": 32000, "w": 2222, "h": 956, "title": "앱 · 메일 목록", "is_interactive": true }, "App1Reader.dc.html": { "x": 3290, "y": 32000, "w": 1346, "h": 956, "title": "앱 · 읽기", "is_interactive": true }, "App1Compose.dc.html": { "x": 4716, "y": 32000, "w": 1784, "h": 956, "title": "앱 · 쓰기", "is_interactive": true }, - "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true } + "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true }, + "App1Search.dc.html": { "x": 0, "y": 33450, "w": 3098, "h": 1028, "title": "앱 · 검색 (찾기 · 결과)", "is_interactive": true }, + "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -71,7 +73,8 @@ "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, "phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 }, - "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 } + "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 }, + "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 } }, "designSystems": [] } -- 2.52.0 From 37f078e65c6d92ab192384dc0834ba672a9cc1dc Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 12:19:45 +0900 Subject: [PATCH 16/32] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=201=EC=B0=A8=20?= =?UTF-8?q?=E2=80=94=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=C2=B7=20=EC=9D=B4=EC=96=B4=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EB=8D=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 위 막대의 [찾기]가 탭 안 검색 화면(/mail/search)을 연다. 웹 14단계 검색과 같은 한도(UTF-16 200자 · 낱말 8개)를 먼저 보고, 찾을 곳은 시작한 메일함 | 전체 메일함이다. 결과 줄은 목록 줄 그대로 맞은 말을 노랑으로 칠하고(회색 미리 보기 안은 글자를 진하게), 결과에서 연 읽기 화면도 제목 · 첨부 이름 · 본문을 칠한다. 이어 찾기는 한 차례 5번 · 20통이고, 모자라면 [더 찾기]로 멈춘다. 결과는 새 메일 확인으로 다시 찾지 않고, 읽기 화면에서 한 일과 붙잡힌 메일의 풀림만 반영한다. 낱말 나누기 · 한 글자 판정은 웹 · 서버와 짝이고, 시험이 백엔드 표본 표를 그대로 읽는다. 시험 750 → 870. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/people/recipient_match.dart | 8 +- flutter/lib/core/theme/app_colors.dart | 4 + flutter/lib/core/theme/app_spacing.dart | 10 + flutter/lib/core/theme/app_theme.dart | 8 + flutter/lib/core/utils/js_case.dart | 12 + .../mail/data/models/mail_search.dart | 259 ++++ .../mail/data/models/message_detail.dart | 14 +- .../mail/data/models/message_summary.dart | 18 + .../data/repositories/mail_repository.dart | 56 +- .../lib/features/mail/domain/mail_html.dart | 50 +- .../features/mail/domain/mailbox_tree.dart | 19 + .../mail/domain/search_highlight.dart | 126 ++ .../lib/features/mail/domain/search_text.dart | 342 +++++ flutter/lib/features/mail/mail_routes.dart | 24 +- .../mail/presentation/mail_row_view.dart | 50 +- .../mail/presentation/mail_search_view.dart | 97 ++ .../mail/presentation/mail_texts.dart | 95 +- .../presentation/pages/mail_list_page.dart | 15 +- .../presentation/pages/mail_search_page.dart | 374 ++++++ .../pages/message_reader_page.dart | 45 +- .../providers/mail_search_controller.dart | 677 ++++++++++ .../providers/mail_search_state.dart | 161 +++ .../providers/message_reader_controller.dart | 69 +- .../providers/message_reader_state.dart | 12 + .../widgets/highlighted_text.dart | 55 + .../widgets/mail_list_states.dart | 46 +- .../presentation/widgets/mail_search_bar.dart | 426 +++++++ .../widgets/mail_search_states.dart | 226 ++++ .../presentation/widgets/message_row.dart | 179 ++- .../widgets/reader_attachments.dart | 21 +- .../presentation/widgets/reader_header.dart | 63 +- .../core/people/recipient_match_test.dart | 1 + .../features/mail/mail_list_page_test.dart | 34 +- .../features/mail/mail_repository_test.dart | 145 +++ .../mail/mail_search_controller_test.dart | 729 +++++++++++ .../features/mail/mail_search_page_test.dart | 1125 +++++++++++++++++ .../features/mail/mail_search_view_test.dart | 306 +++++ .../mail/message_reader_page_test.dart | 9 +- .../features/mail/search_highlight_test.dart | 376 ++++++ .../test/features/mail/search_text_test.dart | 256 ++++ flutter/test/support/mail_fakes.dart | 151 ++- flutter/test/support/ts_table.dart | 146 +++ 42 files changed, 6697 insertions(+), 142 deletions(-) create mode 100644 flutter/lib/core/utils/js_case.dart create mode 100644 flutter/lib/features/mail/data/models/mail_search.dart create mode 100644 flutter/lib/features/mail/domain/search_highlight.dart create mode 100644 flutter/lib/features/mail/domain/search_text.dart create mode 100644 flutter/lib/features/mail/presentation/mail_search_view.dart create mode 100644 flutter/lib/features/mail/presentation/pages/mail_search_page.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_search_controller.dart create mode 100644 flutter/lib/features/mail/presentation/providers/mail_search_state.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/highlighted_text.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_search_states.dart create mode 100644 flutter/test/features/mail/mail_search_controller_test.dart create mode 100644 flutter/test/features/mail/mail_search_page_test.dart create mode 100644 flutter/test/features/mail/mail_search_view_test.dart create mode 100644 flutter/test/features/mail/search_highlight_test.dart create mode 100644 flutter/test/features/mail/search_text_test.dart create mode 100644 flutter/test/support/ts_table.dart diff --git a/flutter/lib/core/people/recipient_match.dart b/flutter/lib/core/people/recipient_match.dart index 9351752..d2c8e78 100644 --- a/flutter/lib/core/people/recipient_match.dart +++ b/flutter/lib/core/people/recipient_match.dart @@ -10,11 +10,13 @@ /// Flutter 는 칸의 글(`TextEditingController.text`)에 조합 중인 글자가 들어 있어 그대로 맞춘다. /// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다. /// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개. -/// - 대소문자는 JS 의 `toLowerCase` 와 같게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않는다(알려진 한계). +/// - 대소문자는 JS 의 `toLowerCase` 에 가깝게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않고, +/// 유니코드 자료의 판이 달라 JS 와 다르게 낮추는 글자(437자 — 대부분 Unicode 16 의 새 글자)가 있다(알려진 한계). library; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/js_case.dart'; import 'package:flutter/foundation.dart'; /// 추천 목록에 보이는 최대 개수 @@ -159,10 +161,6 @@ String? _initialOf(String char) { return syllable == null ? null : _initials[syllable.initial]; } -/// JS 의 `String.prototype.toLowerCase` 와 같은 결과 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. -/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 주소 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 -String jsLowerCase(String value) => value.replaceAll('İ', 'i̇').toLowerCase(); - /// 글자(코드 포인트) 단위로 나눈다 — 이모지 같은 짝 글자를 가르지 않는다(JS `Array.from`) List _codePoints(String value) => [ for (final int rune in value.runes) String.fromCharCode(rune), diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index 1b41a73..c51f380 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -123,6 +123,10 @@ abstract final class AppColors { /// `--color-star` — 중요 표시 static const Color star = Color(0xFFB7791F); + /// `--color-search-mark` — 검색에서 맞은 말의 바탕(글자색은 그대로 — 회색 미리 보기 줄의 강조 안만 [textSecondary]). + /// 짝: 웹 토큰 · 읽기 화면 본문 문서의 `mark` 색(`features/mail/domain/mail_html.dart` — WebView 는 앱 색을 읽지 못해 같은 값을 적는다) + static const Color searchMark = Color(0xFFFCE7A4); + /// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …) static const Color shadow = Color(0xFF10181C); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index aff05e2..fe7c354 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -185,4 +185,14 @@ abstract final class AppSize { /// 알림 안 남은 시간 막대의 두께 ([보내기 취소]) static const double toastCountdownHeight = 3; + + // ── 앱 보드에서 정한 크기 (`App1Search` — 2026-11-07 확정) ── + /// 찾을 곳 줄의 높이 + static const double searchScopeRowHeight = 56; + + /// 찾을 곳 전환 칸의 보이는 높이 — 누르는 곳은 [touchMin] + static const double searchToggleHeight = 40; + + /// 결과 머리(개수 줄)의 높이 + static const double searchCountLineHeight = 36; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 8132840..2f4f40b 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -210,6 +210,7 @@ class AppExtraColors extends ThemeExtension { required this.star, required this.rowChecked, required this.brandTintBorder, + required this.searchMark, }); static const AppExtraColors light = AppExtraColors( @@ -231,6 +232,7 @@ class AppExtraColors extends ThemeExtension { star: AppColors.star, rowChecked: AppColors.rowChecked, brandTintBorder: AppColors.brandTintBorder, + searchMark: AppColors.searchMark, ); /// 시각 · 개수 · 보조 설명 @@ -277,6 +279,9 @@ class AppExtraColors extends ThemeExtension { /// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선 final Color brandTintBorder; + /// 검색에서 맞은 말의 바탕 (`--color-search-mark`) + final Color searchMark; + @override AppExtraColors copyWith({ Color? textMuted, @@ -297,6 +302,7 @@ class AppExtraColors extends ThemeExtension { Color? star, Color? rowChecked, Color? brandTintBorder, + Color? searchMark, }) { return AppExtraColors( textMuted: textMuted ?? this.textMuted, @@ -317,6 +323,7 @@ class AppExtraColors extends ThemeExtension { star: star ?? this.star, rowChecked: rowChecked ?? this.rowChecked, brandTintBorder: brandTintBorder ?? this.brandTintBorder, + searchMark: searchMark ?? this.searchMark, ); } @@ -342,6 +349,7 @@ class AppExtraColors extends ThemeExtension { star: Color.lerp(star, other.star, t)!, rowChecked: Color.lerp(rowChecked, other.rowChecked, t)!, brandTintBorder: Color.lerp(brandTintBorder, other.brandTintBorder, t)!, + searchMark: Color.lerp(searchMark, other.searchMark, t)!, ); } } diff --git a/flutter/lib/core/utils/js_case.dart b/flutter/lib/core/utils/js_case.dart new file mode 100644 index 0000000..084a5cd --- /dev/null +++ b/flutter/lib/core/utils/js_case.dart @@ -0,0 +1,12 @@ +/// 대소문자를 웹 · 서버(JS)에 가깝게 낮춘다 — 받는 사람 추천(`core/people`) · 메일 검색의 접기(`features/mail/domain/search_text.dart`)가 함께 쓴다. +library; + +/// JS 의 `String.prototype.toLowerCase` 를 흉내 낸다 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. +/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 +/// (그 밖에 글자 수를 바꾸며 낮추는 글자는 없다 — 유니코드 SpecialCasing 의 조건 없는 소문자는 이 하나뿐이다). +/// +/// **JS 와 완전히 같지는 않다**(2026-11-07 잼): Dart 와 JS(V8 · ICU)는 유니코드 자료의 판이 달라 437자를 다르게 낮춘다 — +/// 대부분 Unicode 16 에 새로 든 글자다(한글 · 한자 · 라틴 기본 글자는 같다). 알려진 한계 — 받는 사람 추천에서는 그 글자들의 +/// 대소문자를 웹과 다르게 맞출 수 있고, 메일 검색은 한 글자 판정에만 써서 결과에는 영향이 없다(맞추기는 서버가 한다) +String jsLowerCase(String value) => + value.replaceAll('\u0130', 'i\u0307').toLowerCase(); diff --git a/flutter/lib/features/mail/data/models/mail_search.dart b/flutter/lib/features/mail/data/models/mail_search.dart new file mode 100644 index 0000000..7ab64d9 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_search.dart @@ -0,0 +1,259 @@ +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter/foundation.dart'; + +/// 목록 항목의 검색 강조 — 계약 2절 "검색 — 14단계"의 `searchHighlight`(색인 방식으로 찾았을 때만 값이 있다) +@immutable +class MessageListHighlight { + const MessageListHighlight({ + required this.subject, + required this.previewText, + required this.previewRanges, + }); + + /// 그 항목의 제목(서버가 NFC 로 실었다) 안의 맞은 곳 + final List subject; + + /// 보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(잘린 쪽에 "…"), 아니면 엔진의 미리 보기 + final String previewText; + + /// [previewText] 안의 맞은 곳 — 비었을 수 있다(인용 밖의 첫 글을 보일 때) + final List previewRanges; + + /// 모양이 틀리면 null — 강조만 버리고 항목은 살린다 + static MessageListHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final Object? preview = json['preview']; + if (subject == null || preview is! Map) return null; + final Object? text = preview['text']; + final List? ranges = parseSearchRanges(preview['ranges']); + if (text is! String || ranges == null) return null; + return MessageListHighlight( + subject: subject, + previewText: text, + previewRanges: ranges, + ); + } + + @override + bool operator ==(Object other) => + other is MessageListHighlight && + listEquals(other.subject, subject) && + other.previewText == previewText && + listEquals(other.previewRanges, previewRanges); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + previewText, + Object.hashAll(previewRanges), + ); +} + +/// 검색 결과 한 통 — 목록 칸([summary])과 그 응답의 강조 +@immutable +class MailSearchItem { + const MailSearchItem({ + required this.summary, + this.highlight, + this.highlightSupported = false, + }); + + final MessageSummary summary; + + /// 맞은 곳 — 색인 방식으로 찾았을 때만. 엔진 방식 · 모양이 틀린 강조는 null + final MessageListHighlight? highlight; + + /// 응답 항목에 `searchHighlight` 칸이 있었다(14단계 서버 — 값이 null 이어도) — 그 결과에서 연 메일만 강조 쿼리를 싣는다 + /// (14단계 앞 서버는 모르는 쿼리를 400 `VAL_001` 로 막는다) + final bool highlightSupported; + + String get id => summary.id; + + /// 읽기 화면이 바꾼 메일의 표시 · 메일함 · 붙잡힘만 덮는다 — 제목 · 미리 보기 · 강조는 이 응답의 것 그대로(강조 위치가 그 글에 묶여 있다) + MailSearchItem withStateOf(MessageSummary next) => MailSearchItem( + summary: summary.withStateOf(next), + highlight: highlight, + highlightSupported: highlightSupported, + ); + + /// 모양이 틀리면 null(목록 항목과 같은 규칙) — `searchHighlight` 만은 틀려도 강조 없는 것으로 읽는다 + static MailSearchItem? tryParse(Object? json) { + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null || json is! Map) return null; + final bool supported = json.containsKey('searchHighlight'); + return MailSearchItem( + summary: summary, + highlight: supported + ? MessageListHighlight.tryParse(json['searchHighlight']) + : null, + highlightSupported: supported, + ); + } + + @override + bool operator ==(Object other) => + other is MailSearchItem && + other.summary == summary && + other.highlight == highlight && + other.highlightSupported == highlightSupported; + + @override + int get hashCode => Object.hash(summary, highlight, highlightSupported); +} + +/// 읽기 화면의 검색 강조 — 강조 쿼리로 받은 `GET /api/messages/{id}` 의 `searchHighlight`. 위치는 그 응답의 글(NFC) 기준이다 +@immutable +class MessageDetailHighlight { + const MessageDetailHighlight({ + this.subject = const [], + this.attachments = const >{}, + this.textBody = const [], + }); + + /// 제목의 맞은 곳 + final List subject; + + /// 첨부 이름의 맞은 곳 — 첨부 id 마다(맞은 첨부만) + final Map> attachments; + + /// 글 본문(textBody)의 맞은 곳 — 서식 본문은 서버가 맞은 글 조각에 `` 를 넣어 보낸다 + final List textBody; + + /// 모양이 틀리면 null — 강조만 버리고 메일은 연다 + static MessageDetailHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final List? textBody = parseSearchRanges(json['textBody']); + final Object? rawAttachments = json['attachments']; + if (subject == null || + textBody == null || + rawAttachments is! Map) { + return null; + } + final Map> attachments = + >{}; + for (final MapEntry entry in rawAttachments.entries) { + final List? ranges = parseSearchRanges(entry.value); + if (ranges == null) return null; + attachments[entry.key] = ranges; + } + return MessageDetailHighlight( + subject: subject, + attachments: Map>.unmodifiable(attachments), + textBody: textBody, + ); + } + + @override + bool operator ==(Object other) => + other is MessageDetailHighlight && + listEquals(other.subject, subject) && + _sameAttachments(other.attachments, attachments) && + listEquals(other.textBody, textBody); + + static bool _sameAttachments( + Map> a, + Map> b, + ) => + a.length == b.length && + a.entries.every( + (MapEntry> entry) => + b.containsKey(entry.key) && listEquals(b[entry.key], entry.value), + ); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + Object.hashAllUnordered(attachments.keys), + Object.hashAll(textBody), + ); +} + +/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 조건 넷(조건은 2차)만, 빈 칸은 싣지 않는다 +/// (계약 2절 "검색 — 14단계" › 읽기 화면 — `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다) +@immutable +class MessageHighlightQuery { + const MessageHighlightQuery({ + this.q = '', + this.from = '', + this.to = '', + this.subject = '', + this.body = '', + }); + + final String q; + final String from; + final String to; + final String subject; + final String body; + + /// 실을 칸이 없다 — 강조 쿼리 없이 받는다 + bool get isEmpty => + q.isEmpty && + from.isEmpty && + to.isEmpty && + subject.isEmpty && + body.isEmpty; + + /// 쿼리 칸 — 빈 칸은 뺀다(서버의 이름 그대로) + Map toQueryParameters() => { + if (q.isNotEmpty) 'q': q, + if (from.isNotEmpty) 'from': from, + if (to.isNotEmpty) 'to': to, + if (subject.isNotEmpty) 'subject': subject, + if (body.isNotEmpty) 'body': body, + }; + + @override + bool operator ==(Object other) => + other is MessageHighlightQuery && + other.q == q && + other.from == from && + other.to == to && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash(q, from, to, subject, body); +} + +/// 지난 메일 검색 색인의 상태 — `GET /api/search-index` 의 `status` +enum SearchIndexStatus { + /// 지난 메일을 채우는 중 — 그동안의 검색은 엔진 방식(낱말이 통째로 같을 때만)이다 + building, + + /// 준비됐다 — 색인 방식(부분 일치 · 강조) + ready; + + /// 응답 칸을 읽는다 — 모르는 값은 null(안내 줄을 보이지 않는다) + static SearchIndexStatus? parse(Object? value) { + for (final SearchIndexStatus status in SearchIndexStatus.values) { + if (status.name == value) return status; + } + return null; + } +} + +/// 검색 화면을 여는 값 — 라우트의 `extra`. 시작한 메일함(null 이면 받은편지함 — 메일함 목록의 기본 메일함). +/// 검색어는 주소에 싣지 않는다(앱에는 새로고침 · 즐겨찾기가 없다 — 검색어가 남는 곳을 줄인다) +@immutable +class MailSearchStart { + const MailSearchStart({this.mailboxId}); + + final String? mailboxId; +} + +/// 검색 결과에서 연 메일 — 읽기 화면 라우트의 `extra`. +/// 읽기 화면은 [highlight] 로 받아 맞은 말을 칠하고, 머리에 "메일함 받은편지함" 줄을 둔다([scopeMailboxId] 에 있으면 그 메일함) +@immutable +class MailSearchOpen { + const MailSearchOpen({this.highlight, this.scopeMailboxId}); + + /// 강조 쿼리 — 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만, 아니면 null(쿼리 없이 받는다) + final MessageHighlightQuery? highlight; + + /// 한 메일함에서 찾았으면 그 메일함 — 전체 메일함이면 null + final String? scopeMailboxId; +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart index 8db1244..ada1862 100644 --- a/flutter/lib/features/mail/data/models/message_detail.dart +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -1,4 +1,5 @@ import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:flutter/foundation.dart'; @@ -107,6 +108,7 @@ class MessageLargeFile { /// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` · /// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다. /// `replyTo` 도 없으면 빈 목록이다(쓰기 화면의 답장 받는 사람이 쓴다). +/// 14단계의 `searchHighlight`(강조 쿼리로 받았을 때만 값)는 없거나 모양이 틀리면 null 로 읽는다(메일은 연다). @immutable class MessageDetail { const MessageDetail({ @@ -123,6 +125,7 @@ class MessageDetail { this.isBodyTruncated = false, this.attachments = const [], this.largeFiles = const [], + this.searchHighlight, }); /// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송 @@ -154,6 +157,9 @@ class MessageDetail { final List attachments; final List largeFiles; + /// 검색 강조 — 검색 결과에서 강조 쿼리로 받은 응답만(그 응답의 제목 · 첨부 이름 · 글 본문 기준), 그 밖에는 null + final MessageDetailHighlight? searchHighlight; + String get id => summary.id; /// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`) @@ -162,7 +168,7 @@ class MessageDetail { if (!attachment.isInline) attachment, ]; - /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로) + /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문 · 강조는 그대로) MessageDetail withSummary(MessageSummary next) => MessageDetail( summary: next, cc: cc, @@ -177,9 +183,11 @@ class MessageDetail { isBodyTruncated: isBodyTruncated, attachments: attachments, largeFiles: largeFiles, + searchHighlight: searchHighlight, ); - /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`) + /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`). + /// 첨부와 검색 강조도 그 응답의 것으로 함께 바꾼다(강조 위치가 그 응답의 글에 묶여 있다) MessageDetail withBodyOf(MessageDetail other) => MessageDetail( summary: summary, cc: cc, @@ -194,6 +202,7 @@ class MessageDetail { isBodyTruncated: other.isBodyTruncated, attachments: other.attachments, largeFiles: other.largeFiles, + searchHighlight: other.searchHighlight, ); /// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다 @@ -242,6 +251,7 @@ class MessageDetail { isBodyTruncated: truncated, attachments: attachments, largeFiles: _largeFiles(json['largeFiles']), + searchHighlight: MessageDetailHighlight.tryParse(json['searchHighlight']), ); } diff --git a/flutter/lib/features/mail/data/models/message_summary.dart b/flutter/lib/features/mail/data/models/message_summary.dart index 654e5f1..d7e069f 100644 --- a/flutter/lib/features/mail/data/models/message_summary.dart +++ b/flutter/lib/features/mail/data/models/message_summary.dart @@ -107,6 +107,24 @@ class MessageSummary { pendingSend: pendingSend, ); + /// [other] 의 표시(읽음 · 중요) · 메일함 · 붙잡힌 발송만 덮는다 — 제목 · 미리 보기 · 받은 시각은 이 요약 그대로 + /// (검색 결과는 강조 위치가 그 응답의 제목 · 미리 보기에 묶여 있다) + MessageSummary withStateOf(MessageSummary other) => MessageSummary( + id: id, + threadId: threadId, + mailboxIds: other.mailboxIds, + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: receivedAt, + isRead: other.isRead, + isFlagged: other.isFlagged, + hasAttachment: hasAttachment, + size: size, + pendingSend: other.pendingSend, + ); + /// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다 static MessageSummary? tryParse(Object? json) { if (json is! Map) return null; diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index 9e71c72..6c875c0 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -8,6 +8,7 @@ import 'package:arasystem_app/core/api/server_time.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; @@ -27,6 +28,9 @@ abstract final class MailApiPaths { /// 새 메일 확인 — 불투명한 상태 글 static const String mailState = '/mail-state'; + /// 지난 메일 검색 색인의 상태 (14단계) — 조회지만 그 직원의 색인을 만들고 멈춤을 푼다(계약 2절 "검색 색인") + static const String searchIndex = '/search-index'; + /// 첨부 받기 — 표준 래퍼 없는 파일 static String attachment(String messageId, String attachmentId) => '${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}'; @@ -98,18 +102,64 @@ class MailRepository { return page.withServerTime(serverTimeOf(response)); } + /// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. + /// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어가 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다. + /// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다. + /// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + Future> searchMessages({ + required String q, + String? mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + 'q': q, + 'mailboxId': ?mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + cancelToken: cancelToken, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MailSearchItem.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 지난 메일 검색 색인의 상태 — 모르는 값이면 null. 알림은 띄우지 않는다(받지 못하면 화면은 안내 줄을 보이지 않는다 — 계약) + Future searchIndexStatus() async { + final Response response = await _dio.get( + MailApiPaths.searchIndex, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + if (data is! Map) throw _malformed(response); + return SearchIndexStatus.parse(data['status']); + } + /// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`). + /// [highlight] 가 있으면 검색 강조 쿼리(`q` 등 — 빈 칸은 빼고)를 함께 실어 맞은 말의 위치 · `` 를 받는다(14단계 서버만 받는다). /// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다 Future<({MessageDetail detail, DateTime? serverTime})> getMessage( String id, { bool showImages = false, bool silent = true, + MessageHighlightQuery? highlight, }) async { + final Map query = { + if (showImages) kMailImagesQuery: kMailImagesShow, + ...?highlight?.toQueryParameters(), + }; final Response response = await _dio.get( MailApiPaths.message(id), - queryParameters: showImages - ? {kMailImagesQuery: kMailImagesShow} - : null, + queryParameters: query.isEmpty ? null : query, options: silent ? ApiOptions.silent() : null, ); final MessageDetail? detail = MessageDetail.tryParse(response.data); diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart index 0812d29..694f160 100644 --- a/flutter/lib/features/mail/domain/mail_html.dart +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -14,6 +14,7 @@ library; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html_parser; @@ -127,30 +128,54 @@ String? displayedHtmlBody(String? htmlBody) => /// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다. /// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). -/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다 +/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다. +/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `` 로 넣는다 +/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다) SanitizedMailBody mailBodyFragment({ required String? htmlBody, required String? textBody, required bool imagesShown, + List textHighlights = const [], }) { final String? html = displayedHtmlBody(htmlBody); - if (html == null) return _textFragment(textBody); + if (html == null) { + return _textFragment(textBody, highlights: textHighlights); + } final String trimmed = html.trim(); if (trimmed.length > kMaxSanitizedHtmlLength || htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) > kMaxHtmlNestingCost) { - return _textFragment(textBody, htmlOmitted: true); + return _textFragment( + textBody, + htmlOmitted: true, + highlights: textHighlights, + ); } return sanitizeMailHtml(trimmed, imagesShown: imagesShown); } -SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) => - SanitizedMailBody( - fragment: - '
${escapeHtmlText(textBody ?? '')}
', - proxiedTokens: const [], - htmlOmitted: htmlOmitted, - ); +SanitizedMailBody _textFragment( + String? textBody, { + bool htmlOmitted = false, + List highlights = const [], +}) => SanitizedMailBody( + fragment: + '
${highlightedTextHtml(textBody ?? '', highlights)}
', + proxiedTokens: const [], + htmlOmitted: htmlOmitted, +); + +/// 글 → 이스케이프한 HTML — 맞은 곳은 속성 없는 `` 로(웹 `mail-html.ts` 의 `textBodyHtml` 과 같다). +/// 위치는 그 글(강조를 받은 응답의 textBody) 기준이다 — 글 밖 · 거꾸로 된 위치는 버린다([highlightSegments]) +String highlightedTextHtml(String text, List highlights) { + if (highlights.isEmpty) return escapeHtmlText(text); + final StringBuffer out = StringBuffer(); + for (final HighlightSegment segment in highlightSegments(text, highlights)) { + final String escaped = escapeHtmlText(segment.text); + out.write(segment.mark ? '$escaped' : escaped); + } + return out.toString(); +} /// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다. /// @@ -486,7 +511,10 @@ const String _documentStyle = 'a{color:#0E7C86}' 'pre{white-space:pre-wrap}' 'blockquote{margin:0 0 0 4px;padding-left:12px;border-left:2px solid #98A2B3;color:#4A5363}' - '.mail-text{white-space:pre-wrap}'; + '.mail-text{white-space:pre-wrap}' + // 검색에서 맞은 말(14단계 — 서버가 서식 본문에 넣은 · 글 본문은 highlightedTextHtml). 짝: 웹 `MAIL_FRAME_STYLE` 의 mark · + // 웹 토큰 `--color-search-mark` · 앱 `AppColors.searchMark` — 한쪽을 고치면 셋 다. 보낸 사람이 본문에 쓴 도 같은 노랑이다(웹과 같은 알려진 한계) + 'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}'; /// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다 String buildMailDocument(String bodyFragment) => [ diff --git a/flutter/lib/features/mail/domain/mailbox_tree.dart b/flutter/lib/features/mail/domain/mailbox_tree.dart index 1ab4472..d291d00 100644 --- a/flutter/lib/features/mail/domain/mailbox_tree.dart +++ b/flutter/lib/features/mail/domain/mailbox_tree.dart @@ -105,6 +105,25 @@ Mailbox? findMailboxByRole(List mailboxes, MailboxRole role) { return null; } +/// 메일이 든 메일함 — [preferredId](검색한 메일함 등)에 들어 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함. +/// 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null (웹 `mail-list-row.ts` 의 `mailboxOfMessage` 와 같다) +Mailbox? mailboxOfMessage( + List mailboxIds, + List mailboxes, { + String? preferredId, +}) { + final List ids = + preferredId != null && mailboxIds.contains(preferredId) + ? [preferredId] + : mailboxIds; + for (final String id in ids) { + for (final Mailbox mailbox in mailboxes) { + if (mailbox.id == id) return mailbox; + } + } + return null; +} + /// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함 Mailbox? defaultMailboxOf(List mailboxes) { final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox); diff --git a/flutter/lib/features/mail/domain/search_highlight.dart b/flutter/lib/features/mail/domain/search_highlight.dart new file mode 100644 index 0000000..52e4c8b --- /dev/null +++ b/flutter/lib/features/mail/domain/search_highlight.dart @@ -0,0 +1,126 @@ +/// 검색 강조 — 서버가 준 맞은 곳(위치)으로 글을 조각낸다 (순수 함수 — 계약 2절 "검색 — 14단계"). +/// +/// **짝: 웹 `frontend/src/shared/utils/search-highlight.ts`**(버리는 위치 · 짝 글자 · 합치기가 같다). 한쪽을 고치면 둘 다. +/// 화면은 조각을 글 조각(`TextSpan`)으로 그리고(`widgets/highlighted_text.dart`), 읽기 화면의 글 본문은 조각을 이스케이프해 `` 로 넣는다(`mail_html.dart`). +/// +/// 웹과 다른 점: 웹은 글을 NFC 로 맞춘 뒤 자른다. Dart 에는 NFC 가 없어 **다시 맞추지 않는다** — 대신 위치는 늘 그 위치를 준 +/// 응답의 글에만 쓴다(목록은 그 결과 항목의 제목 · 미리 보기, 읽기 화면은 강조를 받은 응답의 제목 · 첨부 이름 · 글 본문 — 서버는 강조가 있으면 그 글을 NFC 로 싣는다). +library; + +/// 맞은 곳 — [시작, 끝) UTF-16 위치 +typedef SearchRange = ({int start, int end}); + +/// 그릴 조각 — [mark] 면 맞은 말 +typedef HighlightSegment = ({String text, bool mark}); + +/// 맞은 곳을 칠할 글 한 줄 — 글과 그 글 안의 맞은 곳(위치를 준 응답의 글이다 — 읽기 화면의 제목) +typedef HighlightedLine = ({String text, List ranges}); + +/// JS 의 `Number.MAX_SAFE_INTEGER` — 이보다 큰 소수 꼴 위치는 정수로 믿지 않는다 +const double _maxSafeInteger = 9007199254740991; + +/// 응답의 맞은 곳 목록(`[[start, end], ...]`)을 읽는다. +/// +/// 목록이 아니거나 칸이 두 수의 목록이 아니면 null — 모양이 틀린 강조는 통째로 버린다(부르는 쪽은 강조 없이 그린다 · 항목은 살린다). +/// 정수가 아닌 위치(소수 부분이 있는 수)는 그 칸만 버린다(웹 `Number.isInteger` 와 같다 — `2.0` 은 정수다). +/// 글 밖 · 거꾸로 된 위치는 여기서 버리지 않는다 — 글을 알아야 하므로 [highlightSegments] 가 버린다 +List? parseSearchRanges(Object? json) { + if (json is! List) return null; + final List ranges = []; + for (final Object? item in json) { + if (item is! List || item.length != 2) return null; + final Object? start = item[0]; + final Object? end = item[1]; + if (start is! num || end is! num) return null; + final int? from = _integerOf(start); + final int? to = _integerOf(end); + if (from == null || to == null) continue; + ranges.add((start: from, end: to)); + } + return List.unmodifiable(ranges); +} + +int? _integerOf(num value) { + if (value is int) return value; + final double number = value.toDouble(); + if (!number.isFinite || + number != number.truncateToDouble() || + number.abs() > _maxSafeInteger) { + return null; + } + return number.toInt(); +} + +/// 그 자리가 짝 글자(이모지 등 BMP 밖 글자)의 가운데다 +bool _splitsPair(String text, int at) { + if (at <= 0 || at >= text.length) return false; + final int before = text.codeUnitAt(at - 1); + final int after = text.codeUnitAt(at); + return before >= 0xD800 && + before <= 0xDBFF && + after >= 0xDC00 && + after <= 0xDFFF; +} + +/// 쓸 수 있는 위치만 — 0 이상 · 글 길이보다 앞에서 시작 · 시작 < 끝, 끝은 글 길이에서 자른다. 시작 차례로 놓고 겹치거나 맞닿은 것은 합친다. +/// 위치가 짝 글자를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자가 깨져 보이지 않게 +List usableSearchRanges(List ranges, String text) { + final int length = text.length; + final List valid = []; + for (final SearchRange range in ranges) { + if (range.start < 0 || range.start >= length || range.end <= range.start) { + continue; + } + final int from = _splitsPair(text, range.start) + ? range.start - 1 + : range.start; + final int cut = range.end < length ? range.end : length; + valid.add((start: from, end: _splitsPair(text, cut) ? cut + 1 : cut)); + } + valid.sort( + (SearchRange a, SearchRange b) => + a.start != b.start ? a.start - b.start : a.end - b.end, + ); + final List merged = []; + for (final SearchRange range in valid) { + if (merged.isNotEmpty && range.start <= merged.last.end) { + final SearchRange last = merged.removeLast(); + merged.add(( + start: last.start, + end: range.end > last.end ? range.end : last.end, + )); + } else { + merged.add(range); + } + } + return merged; +} + +/// 글 → 조각 — 맞은 곳이 없으면 글 한 조각(빈 글이면 조각 없음). 글 밖 · 거꾸로 된 위치는 버린다 +List highlightSegments( + String text, + List? ranges, +) { + if (ranges == null || ranges.isEmpty) { + return text.isEmpty + ? const [] + : [(text: text, mark: false)]; + } + final List segments = []; + int cursor = 0; + for (final SearchRange range in usableSearchRanges(ranges, text)) { + if (range.start > cursor) { + segments.add((text: text.substring(cursor, range.start), mark: false)); + } + segments.add((text: text.substring(range.start, range.end), mark: true)); + cursor = range.end; + } + if (cursor < text.length) { + segments.add((text: text.substring(cursor), mark: false)); + } + return segments; +} + +/// 조각에 맞은 말이 있는가 — 없으면 화면은 강조 없는 글로 그린다 +bool hasMarkedSegment(List segments) => + segments.any((HighlightSegment segment) => segment.mark); diff --git a/flutter/lib/features/mail/domain/search_text.dart b/flutter/lib/features/mail/domain/search_text.dart new file mode 100644 index 0000000..6edf341 --- /dev/null +++ b/flutter/lib/features/mail/domain/search_text.dart @@ -0,0 +1,342 @@ +/// 메일 검색의 낱말 나누기 · 접기(정규화) · 글자 두 개 묶음 · 한도 — 순수 함수 (계약 2절 "검색" · "검색 — 14단계"). +/// +/// **짝: 웹 `frontend/src/shared/utils/search-match.ts`(낱말 나누기 · 접기 · 묶음 · 한 글자 판정) · `mail-search.ts`(다듬기 · 낱말 수), +/// 백엔드 `backend/src/shared/utils/search-query.ts` · `search-text.ts`.** 같은 규칙을 그대로 옮겼다 — 한쪽을 고치면 셋 다. +/// 표본 표는 하나다 — 앱 시험(`test/features/mail/search_text_test.dart`)이 백엔드 `backend/test/search-text-samples.ts` 의 +/// `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽어 견준다(웹 · 백엔드 시험도 같은 표를 쓴다). +/// +/// 화면은 이것으로 한도(200자 · 낱말 8개)를 묻기 전에 보고, "한 글자 낱말뿐이라 본문을 찾지 않은 검색"을 알아본다(결과 머리의 한 줄). +/// 맞은 곳(강조)은 서버가 준다 — 여기서 셈하지 않는다. +/// +/// 웹 · 서버와 다른 점(알려진 한계 — 이 파일은 앱에서 한 글자 판정([isSingleCharSearch])에만 쓰여 검색 결과에는 영향이 없다. 맞추기는 서버가 한다): +/// - Dart 에는 유니코드 NFC 가 없다 — [composeHangul] 이 풀어 쓴 한글 자모(L V T)만 음절로 모은다. +/// 결합 문자가 붙은 라틴 글자(e + U+0301)는 모으지 않아 그런 글자가 든 검색의 한 글자 판정만 어긋날 수 있다(표의 NFD 행은 한글뿐이다). +/// - 유니코드 자료의 판이 JS(V8 · ICU)와 다르다(2026-11-07 잼) — 소문자([jsLowerCase] — 'İ' 만 JS 처럼 두 글자로 낮춘다)는 437자, +/// 정규식의 문자 · 숫자 · 결합 문자(`\p{L}` · `\p{N}` · `\p{M}`) 소속은 4,433자가 다르다. 대부분 Unicode 16 에 새로 든 글자다 +/// (한글 · 한자 · 라틴 기본 글자는 같다). 공백 글자([isSearchSpaceCode] — JS `\s`) · 다듬기(JS `trim`)는 같다. +library; + +import 'package:arasystem_app/core/utils/js_case.dart'; +import 'package:arasystem_app/core/utils/utf16_length.dart'; + +/// 검색어의 최대 길이 — 앞뒤 공백을 뺀 UTF-16 단위(서버 · 웹 `MAIL_SEARCH_MAX_LENGTH` 와 같다) +const int kMailSearchMaxLength = 200; + +/// 검색어 낱말(공백으로 나눈 조각)의 최대 수 — 넘으면 묻지 않는다(서버 · 웹 `MAIL_SEARCH_MAX_WORDS` 와 같다) +const int kMailSearchMaxWords = 8; + +/// 검색어를 나눈 조각의 종류 — 낱말 · 구절(큰따옴표 안) +enum SearchTermKind { word, phrase } + +/// 검색어를 나눈 조각 하나 +typedef SearchTerm = ({SearchTermKind kind, String text}); + +/// 큰따옴표 — 감싼 부분은 구절로 찾는다 +const String _quote = '"'; + +/// NUL(U+0000) — 서버는 색인과 같게 U+FFFD 로 1:1 바꿔 찾는다(판정 안에서만 바꾼다 — 보낼 때는 그대로) +const String _nul = '\u0000'; +const String _replacementCharacter = '\uFFFD'; + +/// 찾을 수 있는 글자 — 문자(\p{L})나 숫자(\p{N}). 하나도 없는 낱말 · 구절(`-` · `…` · 그림 문자뿐)은 버린다 +final RegExp _searchableCharacter = RegExp(r'[\p{L}\p{N}]', unicode: true); + +/// 낱말 앞뒤에 붙은, 문자 · 숫자가 아닌 글자 — 떼고 가운데 낱말로 찾는다('회의,' · '(회의)' → '회의'). +/// 뒤쪽은 "문자 · 숫자 · 결합 문자가 아닌 글자 + 그 뒤의 결합 문자"를 뗀다 — 글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다 +final RegExp _wordEdgePunctuation = RegExp( + r'^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$', + unicode: true, +); + +/// 공백 글자인가 — 낱말 나누기 · 묶음의 경계 · 다듬기(JS 정규식 `\s` · `String.prototype.trim` 과 같은 글자들: +/// 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등). Dart 의 `trim` 은 U+0085 도 지워 쓰지 않는다 +bool isSearchSpaceCode(int code) { + if (code <= 0x20) return code == 0x20 || (code >= 0x09 && code <= 0x0D); + if (code < 0xA0) return false; + return code == 0xA0 || + code == 0x1680 || + (code >= 0x2000 && code <= 0x200A) || + code == 0x2028 || + code == 0x2029 || + code == 0x202F || + code == 0x205F || + code == 0x3000 || + code == 0xFEFF; +} + +/// 앞뒤의 공백 글자([isSearchSpaceCode])를 뺀다 — JS `trim` 과 같다 +String trimSearchSpaces(String text) { + int start = 0; + int end = text.length; + while (start < end && isSearchSpaceCode(text.codeUnitAt(start))) { + start++; + } + while (end > start && isSearchSpaceCode(text.codeUnitAt(end - 1))) { + end--; + } + return start == 0 && end == text.length ? text : text.substring(start, end); +} + +/// 공백 글자로 나눈 조각 — 빈 조각은 뺀다(웹 `text.split(/\s+/u).filter(...)`) +List splitSearchWords(String text) { + final List words = []; + int start = -1; + for (int index = 0; index < text.length; index++) { + if (isSearchSpaceCode(text.codeUnitAt(index))) { + if (start >= 0) words.add(text.substring(start, index)); + start = -1; + } else if (start < 0) { + start = index; + } + } + if (start >= 0) words.add(text.substring(start)); + return words; +} + +/// 검색 글 다듬기 — 앞뒤 공백을 빼고 서버 한도([kMailSearchMaxLength] — UTF-16)에서 자른다. +/// 이모지를 가운데서 자르게 되면 그 글자를 뺀다(웹 `normalizeSearchText` 와 같다) +String normalizeSearchText(String text) => trimSearchSpaces( + truncateUtf16(trimSearchSpaces(text), kMailSearchMaxLength), +); + +/// 낱말 수 — 띄어쓰기로 나눈 조각(따옴표와 상관없이 — 서버의 `countSearchWords` · 웹과 같다) +int countSearchWords(String text) => splitSearchWords(text).length; + +/// 검색어를 낱말 · 구절로 나눈다 — 나온 차례대로, 같은 낱말 · 같은 구절은 한 번만(서버 · 웹과 같다). +/// 큰따옴표 한 쌍 안은 구절(안의 여러 칸 공백은 한 칸으로), 짝이 없는 마지막 큰따옴표는 없는 것으로 본다. +/// 낱말 앞뒤의 문장부호는 떼고, 문자 · 숫자가 하나도 없는 낱말 · 구절은 버린다. 한글 자모를 모으고 NUL 은 U+FFFD 로 바꾼다 +List parseSearchTerms(String query) { + final List segments = composeHangul( + query, + ).replaceAll(_nul, _replacementCharacter).split(_quote); + final List terms = []; + final Set seen = {}; + void add(SearchTermKind kind, String text) { + if (!_searchableCharacter.hasMatch(text)) return; + if (!seen.add('${kind.name}\n$text')) return; + terms.add((kind: kind, text: text)); + } + + for (int index = 0; index < segments.length; index++) { + final String segment = segments[index]; + // 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만(마지막 조각이면 짝이 없다) + final bool isPhrase = index.isOdd && index < segments.length - 1; + if (isPhrase) { + add(SearchTermKind.phrase, splitSearchWords(segment).join(' ')); + continue; + } + for (final String word in splitSearchWords(segment)) { + add(SearchTermKind.word, word.replaceAll(_wordEdgePunctuation, '')); + } + } + return terms; +} + +// ── 접기 · 묶음 (짝: 웹 search-match.ts · 백엔드 search-text.ts) ───────────── + +/// 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) +const int _fullwidthFirst = 0xFF01; +const int _fullwidthLast = 0xFF5E; +const int _fullwidthOffset = 0xFEE0; + +const int _asciiUpperA = 0x41; +const int _asciiUpperZ = 0x5A; +const int _asciiCaseOffset = 0x20; +const int _asciiLimit = 0x80; + +/// 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호(소문자로 바꾸지 않고 건너뛴다) +const int _hangulJamoFirst = 0x1100; +const int _hangulJamoLast = 0x11FF; +const int _cjkFirst = 0x2E80; +const int _cjkLast = 0x9FFF; +const int _hangulSyllableFirst = 0xAC00; +const int _hangulSyllableLast = 0xD7A3; + +const int _highSurrogateFirst = 0xD800; +const int _highSurrogateLast = 0xDBFF; +const int _lowSurrogateFirst = 0xDC00; +const int _lowSurrogateLast = 0xDFFF; +const int _bmpLast = 0xFFFF; + +const int _space = 0x20; + +bool _isHighSurrogate(int code) => + code >= _highSurrogateFirst && code <= _highSurrogateLast; + +bool _isLowSurrogate(int code) => + code >= _lowSurrogateFirst && code <= _lowSurrogateLast; + +/// [index] 에서 시작하는 글자의 UTF-16 폭 — 짝 글자(BMP 밖)면 2 +int _widthAt(String text, int index) { + final int code = text.codeUnitAt(index); + return _isHighSurrogate(code) && + index + 1 < text.length && + _isLowSurrogate(text.codeUnitAt(index + 1)) + ? 2 + : 1; +} + +/// 소문자가 따로 없는 글자인가(빠른 길 — 한글 · 한자) +bool _hasNoCase(int code) => + (code >= _hangulSyllableFirst && code <= _hangulSyllableLast) || + (code >= _cjkFirst && code <= _cjkLast) || + (code >= _hangulJamoFirst && code <= _hangulJamoLast); + +/// ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 +int _foldAsciiCode(int code) => code >= _asciiUpperA && code <= _asciiUpperZ + ? code + _asciiCaseOffset + : code; + +/// ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null. [char] 는 그 코드 포인트의 글(UTF-16 한두 단위) +String? _foldCodePoint(int code, String char) { + if (code >= _fullwidthFirst && code <= _fullwidthLast) { + return String.fromCharCode(_foldAsciiCode(code - _fullwidthOffset)); + } + if (_hasNoCase(code)) return null; + final String lower = jsLowerCase(char); + if (lower == char || lower.length != char.length) return null; + // 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만 + final int first = lower.runes.first; + final int firstWidth = first > _bmpLast ? 2 : 1; + return firstWidth == lower.length ? lower : null; +} + +/// 찾기 · 색인의 접기 글 — 한글 자모를 모은 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기. +/// 결과는 [composeHangul] 한 글과 UTF-16 길이 · 위치가 같다 +String foldText(String text) { + final String source = composeHangul(text); + final StringBuffer folded = StringBuffer(); + int runStart = 0; + int index = 0; + while (index < source.length) { + final int code = source.codeUnitAt(index); + final int width = _widthAt(source, index); + final String? replacement; + if (isSearchSpaceCode(code)) { + // 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1 + replacement = code == _space ? null : ' '; + } else if (code < _asciiLimit) { + final int lowered = _foldAsciiCode(code); + replacement = lowered == code ? null : String.fromCharCode(lowered); + } else { + replacement = _foldCodePoint( + width == 2 ? _codePointAt(source, index) : code, + source.substring(index, index + width), + ); + } + if (replacement != null) { + folded + ..write(source.substring(runStart, index)) + ..write(replacement); + runStart = index + width; + } + index += width; + } + if (runStart == 0) return source; + folded.write(source.substring(runStart)); + return folded.toString(); +} + +/// [index] 의 짝 글자(높은 · 낮은 짝)를 코드 포인트로 +int _codePointAt(String text, int index) { + final int high = text.codeUnitAt(index); + final int low = text.codeUnitAt(index + 1); + return 0x10000 + + ((high - _highSurrogateFirst) << 10) + + (low - _lowSurrogateFirst); +} + +/// 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번). +/// 접은 글([foldText])에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다 +Set gramsOf(String fold, [Set? into]) { + final Set grams = into ?? {}; + String? previous; + int index = 0; + while (index < fold.length) { + final int code = fold.codeUnitAt(index); + final int width = _widthAt(fold, index); + if (isSearchSpaceCode(code)) { + previous = null; + } else { + final String char = fold.substring(index, index + width); + if (previous != null) grams.add('$previous$char'); + previous = char; + } + index += width; + } + return grams; +} + +/// 한 글자 낱말뿐인 검색인가 — 검색어(와 2차의 조건 칸)의 낱말 · 구절을 모두 접어도 글자 두 개 묶음이 하나도 없으면 그렇다 +/// (서버의 search-plan · 웹 `isSingleCharSearch` 와 같은 판정). 그때 서버(색인 방식)는 본문을 찾지 않고 제목 · 사람 · 첨부 이름에서만 찾는다. +/// [texts] 는 검색어 q(와 조건 칸)의 글 — 빈 글은 없는 것이다. 찾을 낱말이 하나도 없으면 아니다 +bool isSingleCharSearch(List texts) { + int terms = 0; + final Set grams = {}; + for (final String text in texts) { + for (final SearchTerm term in parseSearchTerms(text)) { + terms++; + gramsOf(foldText(term.text), grams); + if (grams.isNotEmpty) return false; + } + } + return terms > 0; +} + +// ── 한글 자모 모으기 (NFC 의 한글 부분 — 유니코드 한글 조합식) ───────────── + +const int _sBase = 0xAC00; +const int _lBase = 0x1100; +const int _vBase = 0x1161; +const int _tBase = 0x11A7; +const int _lCount = 19; +const int _vCount = 21; +const int _tCount = 28; +const int _nCount = _vCount * _tCount; +const int _sCount = _lCount * _nCount; + +/// 풀어 쓴 한글(첫소리 · 가운뎃소리 · 끝소리 자모 — 맥에서 온 글 등)을 음절로 모은다 — 유니코드 NFC 의 한글 부분과 같다. +/// 그 밖의 글자는 그대로 둔다(결합 문자의 정렬 · 합성은 하지 않는다). 바뀐 것이 없으면 같은 글을 돌려준다 +String composeHangul(String text) { + if (!_hasHangulJamo(text)) return text; + final StringBuffer out = StringBuffer(); + int? last; + for (int index = 0; index < text.length; index++) { + final int code = text.codeUnitAt(index); + final int? previous = last; + if (previous != null) { + final int lIndex = previous - _lBase; + final int vIndex = code - _vBase; + // 첫소리 + 가운뎃소리 → 받침 없는 음절 + if (lIndex >= 0 && lIndex < _lCount && vIndex >= 0 && vIndex < _vCount) { + last = _sBase + (lIndex * _vCount + vIndex) * _tCount; + continue; + } + // 받침 없는 음절 + 끝소리 → 받침 있는 음절 + final int sIndex = previous - _sBase; + final int tIndex = code - _tBase; + if (sIndex >= 0 && + sIndex < _sCount && + sIndex % _tCount == 0 && + tIndex > 0 && + tIndex < _tCount) { + last = previous + tIndex; + continue; + } + out.writeCharCode(previous); + } + last = code; + } + final int? tail = last; + if (tail != null) out.writeCharCode(tail); + return out.toString(); +} + +/// 모을 자모(첫소리 · 가운뎃소리 · 끝소리)가 하나라도 있는가 — 없으면 글을 다시 쓰지 않는다 +bool _hasHangulJamo(String text) { + for (int index = 0; index < text.length; index++) { + final int code = text.codeUnitAt(index); + if (code >= _lBase && code < _tBase + _tCount) return true; + } + return false; +} diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart index 95ee29b..6ce7e8a 100644 --- a/flutter/lib/features/mail/mail_routes.dart +++ b/flutter/lib/features/mail/mail_routes.dart @@ -1,7 +1,9 @@ import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; import 'package:flutter/widgets.dart'; @@ -16,7 +18,11 @@ abstract final class MailRoutes { static String mailbox(String mailboxId) => '$home/mailboxes/${Uri.encodeComponent(mailboxId)}'; - /// 메일 읽기 (전체 화면 — 탭 막대 없이) + /// 메일 검색 (탭 안 — 연 목록 위에 쌓인다. 탭을 다시 누르면 닫힌다). 시작한 메일함은 라우트의 `extra`([MailSearchStart]), + /// 검색어는 주소에 싣지 않는다(화면 상태 — 앱에는 새로고침 · 즐겨찾기가 없다) + static String get search => '$home/search'; + + /// 메일 읽기 (전체 화면 — 탭 막대 없이). 검색 결과에서 열면 강조 쿼리 · 찾은 메일함을 `extra`([MailSearchOpen])로 넘긴다 static String message(String messageId) => '$home/messages/${Uri.encodeComponent(messageId)}'; @@ -47,8 +53,9 @@ abstract final class MailRoutes { /// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. /// -/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader`, 쓰기는 `App1Compose` 화면이다. +/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader`, 쓰기는 `App1Compose`, 검색은 `App1Search` 화면이다. /// 쓰기를 여는 까닭은 주소의 쿼리(`composeIntentOf(state.uri.queryParameters)`), mailto 로 채울 값은 `state.extra` 의 [ComposePrefill] 이다. +/// 검색의 시작한 메일함은 `state.extra` 의 [MailSearchStart], 검색 결과에서 연 메일의 강조 쿼리는 [MailSearchOpen] 이다. final ModuleRoutes mailModuleRoutes = ModuleRoutes( id: AppModuleId.mail, // 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면 @@ -63,6 +70,15 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes( builder: (BuildContext _, GoRouterState state) => MailListPage(mailboxId: state.pathParameters['mailboxId']), ), + GoRoute( + path: 'search', + builder: (BuildContext _, GoRouterState state) => MailSearchPage( + originMailboxId: switch (state.extra) { + MailSearchStart(:final String? mailboxId) => mailboxId, + _ => null, + }, + ), + ), GoRoute( path: 'messages/:messageId', parentNavigatorKey: context.rootNavigatorKey, @@ -70,6 +86,10 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes( // 메일마다 새 화면 — 다른 메일로 바꿔 끼우면 앞 메일의 상태(펼침 · 받기)를 이어 쓰지 않는다 key: ValueKey(state.pathParameters['messageId']!), messageId: state.pathParameters['messageId']!, + search: switch (state.extra) { + final MailSearchOpen open => open, + _ => null, + }, ), ), GoRoute( diff --git a/flutter/lib/features/mail/presentation/mail_row_view.dart b/flutter/lib/features/mail/presentation/mail_row_view.dart index 2a6462e..bfc6fed 100644 --- a/flutter/lib/features/mail/presentation/mail_row_view.dart +++ b/flutter/lib/features/mail/presentation/mail_row_view.dart @@ -3,12 +3,16 @@ /// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않는다(누르면 쓰기 화면에서 이어 쓴다). /// - 보낸편지함: "받는 사람: 박준호 외 2명". /// - 아직 보내지 않은 내 메일(9단계 `pendingSend`)은 시각 자리가 "보내는 중" · "예약 · 11월 7일 오전 9:00"(청록)이고 고를 수 없다. +/// - 검색 결과(보드 `App1Search`): 제목 · 미리 보기의 맞은 곳(14단계 — 미리 보기는 서버가 준 맞은 말 둘레 한 줄)을 싣는다. +/// 전체 메일함 결과는 줄마다 그 메일의 메일함 — 시각 앞에 메일함 이름, 임시보관함 줄도 "받는 사람: …"(웹 `rowWho` 의 mixed). library; import 'package:arasystem_app/core/utils/korean_format.dart'; import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:flutter/foundation.dart'; @@ -28,6 +32,9 @@ class MailRowView { required this.flagged, required this.hasAttachment, required this.held, + this.mailboxName, + this.subjectHighlights = const [], + this.previewHighlights = const [], }); /// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람 @@ -56,13 +63,23 @@ class MailRowView { /// 아직 보내지 않은 메일 — 고를 수 없다 final bool held; + /// 줄의 메일함 이름 — 전체 메일함 검색 결과에서만(시각 앞), 그 밖에는 null + final String? mailboxName; + + /// [subject] 안의 검색에서 맞은 곳 — 검색 결과에서만 + final List subjectHighlights; + + /// [preview] 안의 검색에서 맞은 곳 — 검색 결과에서만 + final List previewHighlights; + /// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목" String get selectLabel => MailListTexts.selectOne(subject); - /// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례 + /// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · (메일함) · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례. 강조는 읽지 않는다 String get semanticsLabel => [ if (unread) MailListTexts.unreadPrefix, who, + ?mailboxName, time, subject, if (hasAttachment) MailListTexts.attachment, @@ -71,27 +88,33 @@ class MailRowView { ].join(', '); } -/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다) +/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다). +/// +/// 검색 결과는 [highlight](그 결과 항목의 강조 — 제목 · 미리 보기 한 줄의 맞은 곳)를 싣고, 전체 메일함 결과는 [mixed] 와 +/// 줄의 메일함 이름([mailboxName] — 시각 앞)을 준다. [role] 은 줄의 메일함 역할이다(전체 메일함이면 그 메일의 메일함) MailRowView mailRowViewOf( MessageSummary message, { required MailboxRole? role, required DateTime now, + bool mixed = false, + String? mailboxName, + MessageListHighlight? highlight, }) { final bool draft = role == MailboxRole.drafts; final ({String text, bool placeholder, String? initial}) who = _whoOf( message, role, + mixed: mixed, ); final PendingSend? pending = message.pendingSend; + final bool hasSubject = message.subject.isNotEmpty; return MailRowView( who: who.text, whoPlaceholder: who.placeholder, initial: who.initial, - subject: message.subject.isEmpty - ? MailListTexts.noSubject - : message.subject, - subjectPlaceholder: draft && message.subject.isEmpty, - preview: message.preview, + subject: hasSubject ? message.subject : MailListTexts.noSubject, + subjectPlaceholder: draft && !hasSubject, + preview: highlight?.previewText ?? message.preview, time: pending == null ? formatMailListTime(message.receivedAt, now) : pendingSendLabel(pending), @@ -100,13 +123,19 @@ MailRowView mailRowViewOf( flagged: message.isFlagged, hasAttachment: message.hasAttachment, held: message.isHeld, + mailboxName: mailboxName, + subjectHighlights: hasSubject && highlight != null + ? highlight.subject + : const [], + previewHighlights: highlight?.previewRanges ?? const [], ); } ({String text, bool placeholder, String? initial}) _whoOf( MessageSummary message, - MailboxRole? role, -) { + MailboxRole? role, { + required bool mixed, +}) { final List to = message.to; final MailAddress? firstRecipient = to.isEmpty ? null : to.first; switch (role) { @@ -123,7 +152,8 @@ MailRowView mailRowViewOf( ); } return ( - text: names, + // 전체 메일함 결과는 줄마다 메일함이 달라 받는 사람임을 밝힌다(웹 `rowWho` 의 mixed) + text: mixed ? '${MailListTexts.recipientPrefix}$names' : names, placeholder: false, initial: initialOf(firstRecipient.name, fallback: firstRecipient.email), ); diff --git a/flutter/lib/features/mail/presentation/mail_search_view.dart b/flutter/lib/features/mail/presentation/mail_search_view.dart new file mode 100644 index 0000000..1d95cbf --- /dev/null +++ b/flutter/lib/features/mail/presentation/mail_search_view.dart @@ -0,0 +1,97 @@ +/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · 웹 `MailSearchHeader` · `MailSearchNotices` · `useMailListRows`). +/// +/// - 결과 머리: "‘계약’ · 4통" · 찾는 중 "‘계약’ · 찾는 중" · 전체 수를 모르면(`total` null) "‘계약’"(통 수를 보이지 않는다 — 계약). +/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다. +/// - 결과 줄: 한 메일함에서 찾았으면 그 메일함의 규칙(보낸편지함 · 임시보관함), 전체 메일함이면 줄마다 그 메일의 메일함(시각 앞에 이름). +library; + +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; + +/// 결과 머리 아래의 안내 줄 +enum MailSearchNotice { + /// 지난 메일을 검색할 수 있게 준비하는 중 + indexing, + + /// 한 글자 낱말뿐이라 본문은 찾지 않았다 + singleChar, +} + +/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)면 null +String? searchCountLineOf(MailSearchState state) { + final String? query = state.query; + if (query == null || !state.showsHead) return null; + if (state.phase == MailSearchPhase.searching || state.refreshing) { + return MailSearchTexts.searching(query); + } + final int? total = state.total; + return total == null + ? MailSearchTexts.quoted(query) + : MailSearchTexts.count(query, total); +} + +/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다 +MailSearchNotice? searchNoticeOf(MailSearchState state) { + final String? query = state.query; + if (query == null || state.phase != MailSearchPhase.ready) return null; + return switch (state.indexStatus) { + SearchIndexStatus.building => MailSearchNotice.indexing, + SearchIndexStatus.ready => + isSingleCharSearch([query]) ? MailSearchNotice.singleChar : null, + null => null, + }; +} + +/// 찾을 곳 줄의 시작한 메일함 이름 — 메일함 목록을 받기 전이면 받은편지함 주소는 "받은편지함", 그 밖은 "메일함" +String searchOriginNameOf(String? originMailboxId, MailboxesState mailboxes) { + if (originMailboxId == null) { + return mailboxes.byId(mailboxes.defaultMailboxId)?.name ?? + MailListTexts.inbox; + } + return mailboxes.byId(originMailboxId)?.name ?? MailListTexts.mailboxFallback; +} + +/// 줄의 메일함 — 한 메일함에서 찾았으면 그 메일함, 전체 메일함이면 그 메일의 메일함 가운데 아는 첫 메일함(모르면 null) +Mailbox? searchRowMailboxOf( + MailSearchItem item, + MailSearchState state, + MailboxesState mailboxes, +) => state.searchedAll + ? mailboxOfMessage(item.summary.mailboxIds, mailboxes.items) + : mailboxes.byId(state.searchedMailboxId); + +/// 결과 줄 하나 — 목록 줄 그대로 + 맞은 말 강조, 전체 메일함이면 시각 앞에 메일함 이름 +MailRowView searchRowViewOf( + MailSearchItem item, { + required MailSearchState state, + required MailboxesState mailboxes, + required DateTime now, +}) { + final Mailbox? mailbox = searchRowMailboxOf(item, state, mailboxes); + return mailRowViewOf( + item.summary, + role: mailbox?.role, + now: now, + mixed: state.searchedAll, + mailboxName: state.searchedAll ? mailbox?.name : null, + highlight: item.highlight, + ); +} + +/// 결과에서 연 메일에 넘기는 값 — 강조 쿼리는 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만 싣는다 +/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 찾은 메일함은 한 메일함에서 찾았을 때만 +MailSearchOpen searchOpenOf(MailSearchItem item, MailSearchState state) { + final String? query = state.query; + return MailSearchOpen( + highlight: item.highlightSupported && query != null + ? MessageHighlightQuery(q: query) + : null, + scopeMailboxId: state.searchedAll ? null : state.searchedMailboxId, + ); +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 3506270..980a60b 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -295,10 +295,9 @@ abstract final class MailReaderTexts { abstract final class MailListTexts { // ── 위 막대 ── static const String openDrawer = '메일함 열기'; - static const String search = '메일 찾기'; - /// [찾기]를 눌렀을 때 — 검색 화면은 다음 차례다(보드에 없다) - static const String searchNotReady = '메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요.'; + /// [찾기] — 검색 화면을 연다(보드 `App1Search`) + static const String search = '메일 찾기'; /// 메일함 이름을 받기 전의 제목 — 받은편지함 주소(`/mail`)는 [inbox] static const String mailboxFallback = '메일함'; @@ -388,6 +387,96 @@ abstract final class MailListTexts { static const String compose = '메일 쓰기'; } +/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). +/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT`)를 그대로 옮겼다 — +/// 앱에서 새로 쓴 글은 그 줄에 적었다. 목록 줄 · 받지 못함의 글은 [MailListTexts] 를 함께 쓴다 +abstract final class MailSearchTexts { + // ── 위 막대 ── + /// 칸의 안내 글 · 화면 낭독기 이름 (웹 `box`) + static const String box = '메일 검색'; + + /// [←] — 화면 낭독기 + static const String close = '검색 닫기'; + + /// [✕] — 화면 낭독기 + static const String clear = '검색어 지우기'; + + // ── 찾을 곳 ── + static const String scopeLabel = '찾을 곳'; + static const String scopeAll = '전체 메일함'; + + /// 전체 메일함을 고르면 줄 아래에 + static const String scopeAllHint = '휴지통과 스팸메일함은 빼고 찾습니다'; + + // ── 결과 머리 ── + /// 전체 수를 모를 때(`total` null) — "‘계약’" + static String quoted(String query) => '‘$query’'; + + /// "‘계약’ · 4통" + static String count(String query, int count) => + '‘$query’ · ${formatCount(count)}통'; + + /// "‘계약’ · 찾는 중" + static String searching(String query) => '‘$query’ · 찾는 중'; + + /// 결과 목록 — 화면 낭독기 이름 (웹 `title`) + static const String results = '검색 결과'; + + /// 처음 찾는 동안의 뼈대 · 목록 끝에서 이어 찾는 동안 — 화면 낭독기 (웹 `loading`) + static const String loading = '찾는 중'; + + // ── 안내 줄 (웹 `SEARCH_NOTICE_TEXT`) ── + /// 지난 메일의 색인을 만드는 동안(`GET /search-index` 의 building) + static const String indexing = + '지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.'; + + /// 한 글자 낱말뿐이라 본문은 찾지 않았을 때(색인이 준비됐을 때만) + static const String singleChar = + '한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.'; + + // ── 결과 없음 ── + /// "‘해지 통보’에 맞는 메일이 없습니다" + static String emptyTitle(String query) => '‘$query’에 맞는 메일이 없습니다'; + + /// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차) + static const String emptyHint = '다른 말로 찾아 보세요.'; + + /// 색인을 준비하는 동안에만 더하는 한계 안내 (웹 `wordLimitHint`) + static const String wordLimitHint = + '준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.'; + + /// 한 메일함에서 찾았을 때 — 결과 없음 · 그 메일함이 그사이 지워짐(`RES_001`) + static const String searchAll = '전체 메일함에서 찾기'; + + // ── 아직 못 찾음 · 목록 끝 ── + static const String partialTitle = '아직 맞는 메일을 찾지 못했습니다'; + static const String partialHint = + '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.'; + static const String more = '더 찾기'; + + /// [더 찾기]를 누른 동안 — 다시 누르지 못한다 + static const String moreBusy = '찾는 중…'; + + // ── 찾을 말이 너무 많음 ── + static const String tooManyWordsTitle = '찾을 말이 너무 많습니다'; + + /// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색어와 조건에 넣은 낱말은 모두 합쳐 …" + static String tooManyWordsHint(int max) => + '검색어의 낱말은 ${formatCount(max)}개까지 찾을 수 있습니다. 낱말을 줄여 주세요.'; + + // ── 찾기 전 ── + /// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀) + static const String intro = + '보낸 사람, 받는 사람, 제목, 본문, 첨부 파일 이름에서 찾습니다. 여러 낱말을 넣으면 모두 들어간 메일을 찾습니다.'; + + // ── 결과 줄 · 읽기 화면 ── + /// 전체 메일함 결과 줄의 시각 앞 — "보낸편지함 ·" (보드) + static String rowMailbox(String name) => '$name ·'; + + /// 검색 결과에서 연 메일의 머리 — "메일함 받은편지함"의 이름 칸 (웹 `mailboxLine`) + static const String mailboxLine = '메일함'; +} + /// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」) abstract final class MailDrawerTexts { static const String title = '메일함'; diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index 51fe5d0..b6ac4ae 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -5,6 +5,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/router/shell_bar.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; @@ -28,7 +29,7 @@ import 'package:go_router/go_router.dart'; /// 메일 목록 — 앱 보드 `App1MailList` (앱 디자인 README 「메일 목록」). /// -/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(아직 준비 중 — 알림만). 서랍은 탭 막대 위까지 덮는다. +/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(검색 화면 — 이 메일함에서 시작한다). 서랍은 탭 막대 위까지 덮는다. /// - 줄을 누르면 읽기 화면(전체 화면 — 임시보관함은 쓰기 화면에서 이어 쓴다). 읽음 처리는 읽기 화면이 한다. /// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다. /// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인). @@ -81,8 +82,14 @@ class _MailListPageState extends ConsumerState { ); } - void _showSearchNotice() { - ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady); + /// [찾기] — 이 메일함에서 시작하는 검색 화면을 위에 쌓는다(받은편지함 주소는 메일함 목록의 기본 메일함) + void _openSearch(String? mailboxId) { + unawaited( + context.push( + MailRoutes.search, + extra: MailSearchStart(mailboxId: mailboxId), + ), + ); } void _toggle(MessageSummary message) { @@ -177,7 +184,7 @@ class _MailListPageState extends ConsumerState { ? MailListTexts.unread(mailbox.unreadCount) : null, onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)), - onSearch: _showSearchNotice, + onSearch: () => _openSearch(list.mailboxId ?? widget.mailboxId), ); return PopScope( diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart new file mode 100644 index 0000000..6ce8ff1 --- /dev/null +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` (앱 디자인 README 「검색」 — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). +/// +/// - **탭 안 화면**이다 — 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫힌다. +/// 검색어는 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`). +/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다. +/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다. [←] · 뒤로 가기가 검색을 닫는다. +/// - 찾을 곳은 찾기 전부터 보이고, 바꾸면 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다 — 다시 찾으면 자판을 내린다. +/// - 결과 줄은 목록 줄 그대로(고르지 않는다 — 사진은 단추가 아니고 길게 눌러도 아무 일 없다). 누르면 읽기 화면(임시보관함은 쓰기 화면), +/// 14단계 서버의 결과면 강조 쿼리를 함께 넘긴다. 읽기 화면에서 지운 뒤 [되돌리기] 알림이 이 화면 아래에 뜬다([메일 쓰기]는 없다). +/// - 당겨서 새로고침 = 보이는 채로 다시 찾기. 새 메일 확인으로는 다시 찾지 않는다(컨트롤러). +class MailSearchPage extends ConsumerStatefulWidget { + const MailSearchPage({this.originMailboxId, super.key}); + + /// 시작한 메일함 — null 이면 받은편지함(메일함 목록의 기본 메일함) + final String? originMailboxId; + + @override + ConsumerState createState() => _MailSearchPageState(); +} + +class _MailSearchPageState extends ConsumerState { + /// 검색 화면마다 하나 — 같은 메일함에서 다시 열어도 앞 화면의 결과와 섞이지 않게 + static int _instances = 0; + + late final MailSearchArgs _args = MailSearchArgs( + instance: ++_instances, + originMailboxId: widget.originMailboxId, + ); + + final TextEditingController _field = TextEditingController(); + final FocusNode _focus = FocusNode(); + + MailSearchController get _controller => + ref.read(mailSearchProvider(_args).notifier); + + @override + void dispose() { + _field.dispose(); + _focus.dispose(); + super.dispose(); + } + + // ── 하는 일 ── + + /// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다 + void _submit(String text) { + if (_controller.submit(text)) _focus.unfocus(); + } + + /// [✕] — 칸과 결과를 비우고 칸에 초점(자판이 다시 올라온다) + void _clear() { + _field.clear(); + _controller.clear(); + _focus.requestFocus(); + } + + /// [←] — 검색을 닫는다(쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로) + void _close() { + if (context.canPop()) { + context.pop(); + } else { + context.go(MailRoutes.home); + } + } + + /// 찾을 곳 — 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 다시 찾았으면 자판을 내린다(곳만 바꿔 두면 그대로) + void _setScope(bool all) { + if (_controller.setScope(all: all, fieldText: _field.text)) { + _focus.unfocus(); + } + } + + /// 결과 줄 — 읽기 화면(임시보관함은 쓰기 화면에서 이어 쓴다) + void _open(MailSearchItem item) { + final MailSearchState search = ref.read(mailSearchProvider(_args)); + final Mailbox? mailbox = searchRowMailboxOf( + item, + search, + ref.read(mailboxesProvider), + ); + unawaited( + mailbox?.role == MailboxRole.drafts + ? context.push(MailRoutes.composeDraft(item.id)) + : context.push( + MailRoutes.message(item.id), + extra: searchOpenOf(item, search), + ), + ); + } + + @override + Widget build(BuildContext context) { + final MailSearchState search = ref.watch(mailSearchProvider(_args)); + final MailboxesState mailboxes = ref.watch(mailboxesProvider); + // 세션이 바뀌면 컨트롤러는 처음으로 돌아간다 — 칸의 글도 비운다 + ref.listen(mailAccountProvider, (String? previous, String? next) { + if (previous != next) _field.clear(); + }); + final String? countLine = searchCountLineOf(search); + final MailSearchNotice? notice = searchNoticeOf(search); + return Scaffold( + appBar: MailSearchBar( + controller: _field, + focusNode: _focus, + onSubmitted: _submit, + onClear: _clear, + onClose: _close, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchScopeBar( + originName: searchOriginNameOf(widget.originMailboxId, mailboxes), + allMailboxes: search.allMailboxes, + onChanged: _setScope, + ), + if (countLine != null) MailSearchCountLine(countLine), + if (notice != null) MailSearchNoticeLine(_noticeText(notice)), + Expanded( + child: _SearchBody( + search: search, + mailboxes: mailboxes, + onRefresh: _controller.refresh, + onRetry: () => unawaited(_controller.retry()), + onSearchAll: _controller.searchAll, + onLoadMore: () => unawaited(_controller.loadMore()), + onSearchMore: () => unawaited(_controller.searchMore()), + onOpen: _open, + ), + ), + ], + ), + // 읽기 화면에서 지우고 돌아오면 [되돌리기] 알림이 여기 뜬다 — [메일 쓰기]는 없다 + floatingActionButton: MailComposeArea( + key: const ValueKey('search-toast-area'), + showCompose: false, + safeInsets: MediaQuery.paddingOf(context), + ), + floatingActionButtonLocation: const MailComposeAreaLocation(), + ); + } +} + +/// 안내 줄의 글 +String _noticeText(MailSearchNotice notice) => switch (notice) { + MailSearchNotice.indexing => MailSearchTexts.indexing, + MailSearchNotice.singleChar => MailSearchTexts.singleChar, +}; + +/// 결과 자리 — 찾기 전 · 낱말이 너무 많음 · 찾는 중(뼈대) · 받지 못함 · 결과 없음 · 아직 못 찾음 · 결과. +/// 찾은 뒤에는 어느 상태에서도 당겨서 새로고침이 된다 +class _SearchBody extends StatelessWidget { + const _SearchBody({ + required this.search, + required this.mailboxes, + required this.onRefresh, + required this.onRetry, + required this.onSearchAll, + required this.onLoadMore, + required this.onSearchMore, + required this.onOpen, + }); + + final MailSearchState search; + final MailboxesState mailboxes; + final Future Function() onRefresh; + final VoidCallback onRetry; + final VoidCallback onSearchAll; + final VoidCallback onLoadMore; + final VoidCallback onSearchMore; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String query = search.query ?? ''; + final Widget content; + switch (search.phase) { + case MailSearchPhase.idle: + return const Align( + alignment: Alignment.topLeft, + child: MailSearchIntro(), + ); + case MailSearchPhase.tooManyWords: + return const _FillViewport( + child: MailSearchTooManyWords(maxWords: kMailSearchMaxWords), + ); + case MailSearchPhase.searching: + content = ListView( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: const [ + MailListSkeleton(label: MailSearchTexts.loading), + ], + ); + case MailSearchPhase.failed: + final ApiException error = + search.error ?? ApiException.fromCode('SYS_001'); + content = _FillViewport( + child: MailListError( + error: error, + onRetry: onRetry, + // 찾던 메일함이 그사이 지워졌으면(RES_001) 다시 해도 같다 — 전체 메일함에서 찾는다 + onLeave: search.searchedAll ? null : onSearchAll, + leaveLabel: MailSearchTexts.searchAll, + titleStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + ); + // 아직 못 찾음 — 한 차례가 하나도 못 찾고 멈췄거나, 불러온 결과를 모두 뺐는데 서버에 더 있다(목록 끝이 없어 [더 찾기]로 잇는다) + case MailSearchPhase.ready + when search.items.isEmpty && search.waitsForMore: + content = _FillViewport( + child: MailSearchPartial( + busy: search.fill != MailSearchFill.none, + onMore: onSearchMore, + ), + ); + case MailSearchPhase.ready when search.items.isEmpty: + content = _FillViewport( + child: MailSearchEmpty( + query: query, + indexing: search.indexStatus == SearchIndexStatus.building, + onSearchAll: search.searchedAll ? null : onSearchAll, + ), + ); + case MailSearchPhase.ready: + content = _ResultList( + search: search, + mailboxes: mailboxes, + onLoadMore: onLoadMore, + onSearchMore: onSearchMore, + onOpen: onOpen, + ); + } + return RefreshIndicator( + onRefresh: onRefresh, + color: scheme.primary, + backgroundColor: scheme.surface, + semanticsLabel: MailSearchTexts.loading, + child: content, + ); + } +} + +/// 가운데 상태 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다(끌면 자판을 내린다) +class _FillViewport extends StatelessWidget { + const _FillViewport({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: child)), + ], + ); + } +} + +/// 결과 목록 — 목록 줄 그대로(고르지 않는다) + 끝 줄(이어 찾는 중 · [다시 시도] · [더 찾기]) +class _ResultList extends ConsumerWidget { + const _ResultList({ + required this.search, + required this.mailboxes, + required this.onLoadMore, + required this.onSearchMore, + required this.onOpen, + }); + + final MailSearchState search; + final MailboxesState mailboxes; + final VoidCallback onLoadMore; + final VoidCallback onSearchMore; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다 + final DateTime now = ref.read(clockProvider)(); + // [되돌리기] 알림이 떠 있으면 마지막 줄이 가리지 않게(보드 「읽기 화면에서 지운 뒤」의 아래 여백 96) + final bool toast = ref.watch(mailToastProvider) != null; + final List items = search.items; + final Widget? footer = _footer(); + return Semantics( + container: true, + explicitChildNodes: true, + label: MailSearchTexts.results, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + bottom: toast ? AppSize.fabHeight + AppSpacing.s40 : AppSpacing.s24, + ), + itemCount: items.length + (footer == null ? 0 : 1), + itemBuilder: (BuildContext context, int index) { + if (index >= items.length) return footer!; + final MailSearchItem item = items[index]; + return MessageRow( + key: ValueKey(item.id), + view: searchRowViewOf( + item, + state: search, + mailboxes: mailboxes, + now: now, + ), + selected: false, + onTap: () => onOpen(item), + ); + }, + ), + ); + } + + /// 끝 줄 — [더 찾기](한 차례가 한 쪽을 못 채움) · [다시 시도](이어 찾다 실패) · 이어 찾는 중 · 끝 표시(보이면 다음 차례) + Widget? _footer() { + if (search.partial) { + return MailSearchMoreLine( + busy: search.fill == MailSearchFill.more, + onMore: onSearchMore, + ); + } + if (search.loadMoreFailed) { + return MailListFooter( + loading: false, + failed: true, + onReached: onLoadMore, + onRetry: onLoadMore, + loadingLabel: MailSearchTexts.loading, + ); + } + if (search.hasMore || search.fill == MailSearchFill.auto) { + return MailListFooter( + loading: search.fill == MailSearchFill.auto, + failed: false, + onReached: onLoadMore, + onRetry: onLoadMore, + loadingLabel: MailSearchTexts.loading, + ); + } + return null; + } +} diff --git a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart index 42809e8..d772425 100644 --- a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart @@ -4,12 +4,14 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/domain/mail_links.dart'; import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart'; @@ -47,12 +49,17 @@ const double kReaderHeadMaxFraction = 0.5; /// (보드 `App1Compose` 「보낸 뒤」 — 막대 위 12). /// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다). /// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게). +/// - 검색 결과에서 열었으면([search] — 라우트의 `extra`) 강조 쿼리로 받아 맞은 말을 칠하고, 머리에 "메일함 받은편지함" 줄을 둔다 +/// (보드 `App1SearchStates` 「결과에서 연 메일」). 지우고 옮긴 뒤에는 검색 결과로 돌아간다. class MessageReaderPage extends ConsumerStatefulWidget { - const MessageReaderPage({required this.messageId, super.key}); + const MessageReaderPage({required this.messageId, this.search, super.key}); /// 주소의 메일 id final String messageId; + /// 검색 결과에서 열었다 — 강조 쿼리 · 찾은 메일함(그 밖에는 null) + final MailSearchOpen? search; + @override ConsumerState createState() => _MessageReaderPageState(); } @@ -61,8 +68,12 @@ class _MessageReaderPageState extends ConsumerState { /// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다) bool _heldDialogOpen = false; + /// 이 화면의 읽기 상태를 여는 값 — 메일 id · 검색 강조 쿼리 + MessageReaderArgs get _args => + MessageReaderArgs(widget.messageId, highlight: widget.search?.highlight); + MessageReaderController get _controller => - ref.read(messageReaderProvider(widget.messageId).notifier); + ref.read(messageReaderProvider(_args).notifier); /// 목록으로 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로. /// 읽기 화면이 맨 위일 때만 닫는다 — 일이 끝나기를 기다리는 사이 위에 다른 화면이 열렸으면 그 화면을 닫지 않는다 @@ -154,7 +165,7 @@ class _MessageReaderPageState extends ConsumerState { void _onNavigation(MailNavigation navigation) { // 삭제 · 이동 · 스팸 등을 하는 동안은 열지 않는다 — 끝나면 목록으로 돌아간다 - if (ref.read(messageReaderProvider(widget.messageId)).busy != null) return; + if (ref.read(messageReaderProvider(_args)).busy != null) return; switch (navigation) { case OpenExternally(:final Uri uri): unawaited(_openExternally(uri)); @@ -177,13 +188,11 @@ class _MessageReaderPageState extends ConsumerState { @override Widget build(BuildContext context) { - final MessageReaderState reader = ref.watch( - messageReaderProvider(widget.messageId), - ); + final MessageReaderState reader = ref.watch(messageReaderProvider(_args)); final MailboxesState mailboxes = ref.watch(mailboxesProvider); // 받은편지함 위에서 연 화면이 사라질 때까지 [되돌리기] 알림을 쥔다(컨트롤러도 쥔다) ref.listen(mailToastProvider, (MailToast? _, MailToast? _) {}); - ref.listen(messageReaderProvider(widget.messageId), ( + ref.listen(messageReaderProvider(_args), ( MessageReaderState? previous, MessageReaderState next, ) { @@ -235,6 +244,13 @@ class _MessageReaderPageState extends ConsumerState { MessageReaderStatus.ready => _ReaderContent( messageId: widget.messageId, reader: reader, + mailboxName: widget.search == null || detail == null + ? null + : mailboxOfMessage( + detail.summary.mailboxIds, + mailboxes.items, + preferredId: widget.search?.scopeMailboxId, + )?.name, onShowImages: () => unawaited(_controller.showImages()), onCancelHeld: () => unawaited(_cancelHeld()), onNavigation: _onNavigation, @@ -338,6 +354,7 @@ class _ReaderContent extends ConsumerWidget { const _ReaderContent({ required this.messageId, required this.reader, + required this.mailboxName, required this.onShowImages, required this.onCancelHeld, required this.onNavigation, @@ -345,6 +362,9 @@ class _ReaderContent extends ConsumerWidget { final String messageId; final MessageReaderState reader; + + /// 검색 결과에서 연 메일의 메일함 이름 — 그 밖에는 null + final String? mailboxName; final VoidCallback onShowImages; final VoidCallback onCancelHeld; final void Function(MailNavigation navigation) onNavigation; @@ -376,7 +396,13 @@ class _ReaderContent extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ReaderHeader(detail: detail, myEmail: myEmail, now: now), + ReaderHeader( + detail: detail, + myEmail: myEmail, + now: now, + subjectHighlight: reader.subjectHighlight, + mailboxName: mailboxName, + ), ReaderNotices( detail: detail, htmlOmitted: reader.htmlOmitted, @@ -389,6 +415,9 @@ class _ReaderContent extends ConsumerWidget { ReaderAttachments( attachments: attachments, largeFiles: detail.largeFiles, + nameHighlights: + detail.searchHighlight?.attachments ?? + const >{}, downloads: downloads, onDownload: (MessageAttachment attachment) => unawaited(downloader.download(attachment)), diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart new file mode 100644 index 0000000..3d48e89 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart @@ -0,0 +1,677 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +/// 이어 찾는 한 차례에 보내는 요청의 최대 수 — 처음 찾을 때 · 목록 끝에 닿았을 때 · [더 찾기] 마다(웹 `MAIL_SEARCH_FILL_MAX_REQUESTS` 와 같다). +/// 서버(색인 방식)는 한 요청에 3,000통까지 보므로 5번이면 1만 5천 통을 훑는다 — 넘으면 사용자가 [더 찾기]로 잇는다(계약 2절 "이어 찾기") +const int kMailSearchFillMaxRequests = 5; + +/// 검색 화면 하나 — 화면(State)마다 하나([MailSearchArgs.instance]). 화면이 닫히면 버린다(autoDispose — 도는 요청도 끊는다) +final NotifierProviderFamily< + MailSearchController, + MailSearchState, + MailSearchArgs +> +mailSearchProvider = NotifierProvider.autoDispose + .family( + MailSearchController.new, + ); + +/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙. +/// +/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다. +/// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다. +/// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다. +/// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다. +/// - **늦은 응답**: 검색 세대([_seq] — 새 검색 · 곳 바꿈 · 다시 찾기 · [✕] · 세션 바뀜마다)가 그대로일 때만 응답을 쓰고, +/// 앞 검색의 요청은 끊는다(화면을 닫아도 — 끊은 요청은 알리지 않는다). 준비 상태는 따로 세대([_indexSeq])를 둔다. +/// - **결과 갱신**: 새 메일 확인으로는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게 — 웹과 같다). 이 앱의 읽기 화면이 바꾼 것만 +/// 바로 반영한다(`MailChange` 의 `updated` · `removedIds`) — 표시 · 메일함 · 붙잡힘만 덮고 제목 · 미리 보기 · 강조는 그대로, +/// 찾은 곳을 떠난 메일은 빼서 이 검색 동안 기억했다가 [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에 다시 넣는다 +/// (불러온 범위 안에서 뺀 메일이다 — 서버 커서는 불러온 끝 줄의 다음부터 주므로 다음 쪽이 가져오지 않는다). +/// - **붙잡힌 메일**(보내는 중 · 예약): 보낼 시각 + 2초(서버 시계)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다 — +/// 엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다(목록 · 읽기 화면과 같은 규칙 · 웹 `usePendingSendRefresh`). 없어졌으면 뺀다. +/// - 세션이 바뀌면 처음(찾기 전)으로 돌아간다. +class MailSearchController extends Notifier { + MailSearchController(this.args); + + final MailSearchArgs args; + + int _seq = 0; + int _indexSeq = 0; + + /// 지금 검색 세대의 요청을 끊는 손잡이 + CancelToken? _cancel; + + /// 이 검색에서 찾은 곳을 떠나 뺀 메일 — [되돌리기]로 다시 들어오면 그 자리에 다시 넣는다(id 마다, 뺄 때의 모습) + final Map _removed = {}; + + /// 읽기 화면이 결과를 바꾼 횟수 — 그 전에 떠난 붙잡힌 줄 다시 받기의 응답은 옛 상태라 버린다 + int _localGeneration = 0; + + /// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡힌 발송이 풀릴 때를 서버 시계로 잰다) + Duration _serverOffset = Duration.zero; + + /// 붙잡힌 줄을 다시 받을 때 + Timer? _heldTimer; + + /// 도는 붙잡힌 줄 다시 받기의 차례 번호 — null 이면 돌지 않는다(도는 동안은 다음 때를 잡지 않고 끝난 뒤에 잡는다) + int? _heldRun; + int _heldRuns = 0; + + /// 시각이 지난 채 다시 본 횟수 — 메일 · 보낼 시각마다([_heldKey]) + final Map _heldRechecks = {}; + + MailRepository get _repository => ref.read(mailRepositoryProvider); + + Clock get _clock => ref.read(clockProvider); + + @override + MailSearchState build() { + ref.watch(mailAccountProvider); + _seq++; + _indexSeq++; + _cancelRequests(); + _removed.clear(); + _serverOffset = Duration.zero; + _heldRun = null; + _heldRechecks.clear(); + _cancelHeldTimer(); + ref + ..onDispose(_cancelRequests) + ..onDispose(_cancelHeldTimer); + ref.listen(mailStateWatcherProvider, ( + MailChange? previous, + MailChange next, + ) { + // 처음부터 다시 만들 때의 0 은 바뀜이 아니다 + if (next.seq > (previous?.seq ?? 0)) _onLocalChange(next); + }); + return const MailSearchState(); + } + + void _cancelRequests() { + _cancel?.cancel(); + _cancel = null; + } + + /// 새 검색 세대 — 앞 세대의 요청을 끊고 새 손잡이를 둔다. 붙잡힌 줄 다시 보기도 새 결과가 오면 다시 잡는다 + /// (도는 다시 받기는 세대가 바뀌어 응답을 버린다 — 기다리지 않는다) + int _beginGeneration() { + _seq++; + _cancelRequests(); + _cancelHeldTimer(); + _heldRun = null; + _heldRechecks.clear(); + _cancel = CancelToken(); + return _seq; + } + + bool _alive(int seq) => ref.mounted && seq == _seq; + + // ── 하는 일 ───────────────────────────────────────── + + /// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다). + /// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다 + bool submit(String text) { + if (!ref.mounted) return false; + final String q = normalizeSearchText(text); + if (q.isEmpty) return false; + if (countSearchWords(q) > kMailSearchMaxWords) { + _beginGeneration(); + _removed.clear(); + state = MailSearchState( + phase: MailSearchPhase.tooManyWords, + allMailboxes: state.allMailboxes, + indexStatus: state.indexStatus, + ); + return true; + } + final MailSearchState current = state; + if (current.phase == MailSearchPhase.ready && + current.query == q && + current.searchedAll == current.allMailboxes) { + unawaited(_search(q, keepVisible: true)); + } else { + unawaited(_search(q)); + } + return true; + } + + /// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 둘 다 없으면 곳만 바꿔 둔다. + /// 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다) + bool setScope({required bool all, required String fieldText}) { + if (!ref.mounted || state.allMailboxes == all) return false; + state = state.copyWith(allMailboxes: all); + final String typed = normalizeSearchText(fieldText); + final String? q = typed.isNotEmpty ? typed : state.query; + return q != null && submit(q); + } + + /// [전체 메일함에서 찾기] — 보이는 결과의 검색어를 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐) + void searchAll() { + if (!ref.mounted) return; + final String? q = state.query; + state = state.copyWith(allMailboxes: true); + if (q != null) unawaited(_search(q)); + } + + /// [✕] — 결과를 비우고 찾기 전으로(찾을 곳은 그대로) + void clear() { + if (!ref.mounted) return; + _beginGeneration(); + _removed.clear(); + state = MailSearchState( + allMailboxes: state.allMailboxes, + indexStatus: state.indexStatus, + ); + } + + /// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry]) + Future refresh() async { + if (!ref.mounted) return; + final String? q = state.query; + if (q == null) return; + switch (state.phase) { + case MailSearchPhase.ready: + await _search(q, keepVisible: true); + case MailSearchPhase.failed: + await _search(q); + case MailSearchPhase.idle: + case MailSearchPhase.tooManyWords: + case MailSearchPhase.searching: + break; + } + } + + /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다 + Future retry() async { + if (!ref.mounted) return; + final String? q = state.query; + if (q != null) await _search(q); + } + + /// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만) + Future loadMore() async { + if (!ref.mounted) return; + final MailSearchState current = state; + final CancelToken? cancel = _cancel; + if (current.phase != MailSearchPhase.ready || + current.fill != MailSearchFill.none || + current.refreshing || + current.partial || + !current.hasMore || + cancel == null) { + return; + } + state = current.copyWith(fill: MailSearchFill.auto, loadMoreFailed: false); + await _fillRound( + _seq, + cancel, + found: 0, + sent: 0, + fill: MailSearchFill.auto, + ); + } + + /// [더 찾기] — 한 쪽을 못 채우고 멈춘 차례 · 불러온 결과를 모두 뺀 뒤(목록 끝이 없다)를 잇는다(누른 동안은 다시 누르지 못한다) + Future searchMore() async { + if (!ref.mounted) return; + final MailSearchState current = state; + final CancelToken? cancel = _cancel; + if (current.phase != MailSearchPhase.ready || + current.fill != MailSearchFill.none || + current.refreshing || + !current.waitsForMore || + cancel == null) { + return; + } + state = current.copyWith(fill: MailSearchFill.more, loadMoreFailed: false); + await _fillRound( + _seq, + cancel, + found: 0, + sent: 0, + fill: MailSearchFill.more, + ); + } + + // ── 찾기 ────────────────────────────────────────── + + /// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), + /// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다) + Future _search(String q, {bool keepVisible = false}) async { + final Ref ref = this.ref; + final int seq = _beginGeneration(); + final CancelToken cancel = _cancel!; + _removed.clear(); + final bool all = state.allMailboxes; + state = keepVisible + ? state.copyWith( + refreshing: true, + fill: MailSearchFill.none, + loadMoreFailed: false, + ) + : MailSearchState( + phase: MailSearchPhase.searching, + allMailboxes: all, + query: q, + searchedAll: all, + indexStatus: state.indexStatus, + ); + unawaited(_refreshIndexStatus()); + String? mailboxId; + if (!all) { + mailboxId = await _originMailboxId(); + if (!_alive(seq)) return; + if (mailboxId == null) { + // 시작한 메일함을 알 수 없다(메일함 목록을 받지 못함) — 그 실패를 받지 못함으로 보인다 + state = state.copyWith( + phase: MailSearchPhase.failed, + refreshing: false, + error: + ref.read(mailboxesProvider).failure ?? + ApiException.fromCode(kFallbackCode), + ); + return; + } + } + try { + final MailPage page = await _repository.searchMessages( + q: q, + mailboxId: mailboxId, + silent: !keepVisible, + cancelToken: cancel, + ); + if (!_alive(seq)) return; + _noteServerTime(page.serverTime); + final List items = _unique(page.items); + state = MailSearchState( + phase: items.isEmpty && page.pageInfo.hasMore + ? MailSearchPhase.searching + : MailSearchPhase.ready, + allMailboxes: state.allMailboxes, + query: q, + searchedAll: all, + searchedMailboxId: mailboxId, + items: List.unmodifiable(items), + pageInfo: page.pageInfo, + indexStatus: state.indexStatus, + ); + await _fillRound( + seq, + cancel, + found: items.length, + sent: 1, + fill: MailSearchFill.auto, + ); + } on DioException catch (error) { + if (!_alive(seq)) return; + if (keepVisible) { + state = state.copyWith(refreshing: false); + // 보이는 결과는 그대로다 — 붙잡힌 줄을 다시 볼 때도 다시 잡는다 + _scheduleHeldCheck(); + return; + } + state = state.copyWith( + phase: MailSearchPhase.failed, + error: toApiException(error), + ); + } + } + + /// 이어 찾는 한 차례 — 한 쪽(20통)이 새로 차거나 더 없거나 요청이 [kMailSearchFillMaxRequests] 번이 될 때까지 커서로 묻는다. + /// [found] · [sent] 는 이 차례에서 이미 찾은 수 · 보낸 요청 수. 하나도 찾기 전의 요청은 알리지 않는다(실패하면 받지 못함). + /// 그 뒤의 실패는 인터셉터가 알리고 받은 데까지 보인다(끝의 [다시 시도] — 아직 하나도 없으면 [더 찾기]) + Future _fillRound( + int seq, + CancelToken cancel, { + required int found, + required int sent, + required MailSearchFill fill, + }) async { + int added = found; + int requests = sent; + bool stalled = false; + bool failed = false; + while (_alive(seq) && + requests < kMailSearchFillMaxRequests && + added < kMailPageSize && + state.hasMore) { + final MailSearchState current = state; + final String cursor = current.pageInfo!.nextCursor!; + final bool beforeAny = current.phase == MailSearchPhase.searching; + if (current.fill != fill) state = current.copyWith(fill: fill); + final MailPage page; + try { + page = await _repository.searchMessages( + q: current.query!, + mailboxId: current.searchedMailboxId, + cursor: cursor, + silent: beforeAny, + cancelToken: cancel, + ); + } on DioException catch (error) { + if (!_alive(seq)) return; + if (beforeAny) { + state = state.copyWith( + phase: MailSearchPhase.failed, + fill: MailSearchFill.none, + error: toApiException(error), + ); + return; + } + failed = true; + break; + } + if (!_alive(seq)) return; + _noteServerTime(page.serverTime); + requests++; + final List fresh = _fresh(page.items); + // 이 검색에서 뺐던 메일이 다음 쪽에 왔다(다른 곳에서 되돌림) — 기억을 지우고 뺄 때 줄인 수를 되돌린다 + int revived = 0; + for (final MailSearchItem item in fresh) { + if (_removed.remove(item.id) != null) revived++; + } + added += fresh.length; + stalled = fresh.isEmpty && page.pageInfo.nextCursor == cursor; + final List items = [ + ...state.items, + ...fresh, + ]; + // 다음 쪽은 늘 null 이다(색인 방식) — 첫 쪽의 수를 남긴다 + final int? total = page.pageInfo.total ?? state.pageInfo?.total; + state = state.copyWith( + phase: items.isEmpty ? state.phase : MailSearchPhase.ready, + items: items, + pageInfo: page.pageInfo.copyWith( + total: total == null || page.pageInfo.total != null + ? total + : total + revived, + ), + ); + if (stalled) break; + } + if (!_alive(seq)) return; + final bool more = state.hasMore; + final bool empty = state.items.isEmpty; + final bool halted = failed || stalled; + state = state.copyWith( + phase: MailSearchPhase.ready, + fill: MailSearchFill.none, + refreshing: false, + partial: more && (halted ? empty : added < kMailPageSize), + loadMoreFailed: halted && !empty, + ); + _scheduleHeldCheck(); + } + + /// 첫 쪽 — 쪽 안에서 겹친 메일은 한 번만 + List _unique(List items) { + final Set seen = {}; + return [ + for (final MailSearchItem item in items) + if (seen.add(item.id)) item, + ]; + } + + /// 다음 쪽 — 지금 목록에 있는 메일은 건너뛴다(id — 그사이 새 메일이 와서 경계가 밀렸을 수 있다). + /// 이 검색에서 뺀 메일이어도 서버가 준 것이면 넣는다(다른 곳에서 되돌린 메일을 숨기지 않게) + List _fresh(List items) { + final Set known = { + for (final MailSearchItem item in state.items) item.id, + }; + return [ + for (final MailSearchItem item in items) + if (known.add(item.id)) item, + ]; + } + + /// 한 메일함에서 찾을 때의 그 메일함 — 시작한 메일함, 받은편지함 주소면 메일함 목록의 기본 메일함(받기 전이면 받아 본다) + Future _originMailboxId() async { + final String? fixed = args.originMailboxId; + if (fixed != null) return fixed; + final String? known = ref.read(mailboxesProvider).defaultMailboxId; + if (known != null) return known; + await ref.read(mailboxesProvider.notifier).reload(); + if (!ref.mounted) return null; + return ref.read(mailboxesProvider).defaultMailboxId; + } + + /// 지난 메일 검색 색인의 상태를 조용히 묻는다 — 찾을 때마다. 받지 못하면(실패 · 14단계 앞 서버) 모름(null) + Future _refreshIndexStatus() async { + final Ref ref = this.ref; + final int seq = ++_indexSeq; + final MailRepository repository = _repository; + SearchIndexStatus? status; + try { + status = await repository.searchIndexStatus(); + } on DioException { + status = null; + } + if (!ref.mounted || seq != _indexSeq) return; + state = state.copyWith(indexStatus: status); + } + + // ── 이 앱이 바꾼 것 반영 ───────────────────────────── + + /// 읽기 화면이 바꾼 메일 — 결과에 바로 반영한다(새 메일 확인 소식만으로는 다시 찾지 않는다) + void _onLocalChange(MailChange change) { + if (!ref.mounted || !change.hasLocalChanges) return; + _localGeneration++; + _applyChanges(updated: change.updated, removedIds: change.removedIds); + } + + /// 바뀐 메일을 결과에 덮는다 — 표시 · 메일함 · 붙잡힘만 덮고, 찾은 곳을 떠난 메일은 빼서 기억한다. + /// [restore] 면 기억한 메일이 다시 찾은 곳에 들어왔을 때 그 자리에 다시 넣는다([되돌리기] — 붙잡힌 줄 다시 받기는 지금 줄만 덮는다) + void _applyChanges({ + required List updated, + required Set removedIds, + bool restore = true, + }) { + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final Map byId = { + for (final MessageSummary summary in updated) summary.id: summary, + }; + bool changed = false; + int removed = 0; + int restored = 0; + final List kept = []; + for (final MailSearchItem item in current.items) { + if (removedIds.contains(item.id)) { + changed = true; + removed++; + continue; + } + final MessageSummary? next = byId[item.id]; + if (next == null) { + kept.add(item); + continue; + } + final MailSearchItem refreshed = item.withStateOf(next); + if (_inScope(next.mailboxIds)) { + if (refreshed != item) changed = true; + kept.add(refreshed); + } else { + _removed[item.id] = refreshed; + removed++; + changed = true; + } + } + if (restore) { + // [되돌리기]로 다시 찾은 곳에 들어온 메일 — 불러온 범위 안에서 뺀 메일이라 늘 받은 시각 차례의 그 자리에 다시 넣는다 + // (불러온 끝 줄이어도 — 서버 커서는 그 줄 다음부터 준다). 이미 있으면(id) 넣지 않는다 + final Set listed = { + for (final MailSearchItem item in kept) item.id, + }; + for (final MessageSummary next in updated) { + final MailSearchItem? gone = _removed[next.id]; + if (gone == null || listed.contains(next.id)) continue; + if (!_inScope(next.mailboxIds)) { + _removed[next.id] = gone.withStateOf(next); + continue; + } + _removed.remove(next.id); + final MailSearchItem back = gone.withStateOf(next); + kept.insert(_placeOf(kept, back), back); + listed.add(back.id); + restored++; + changed = true; + } + } + removedIds.forEach(_removed.remove); + if (!changed) return; + final MailPageInfo? info = current.pageInfo; + final int? total = info?.total; + state = current.copyWith( + items: kept, + pageInfo: info != null && total != null && removed != restored + ? info.copyWith(total: math.max(0, total - removed + restored)) + : info, + ); + _scheduleHeldCheck(); + } + + /// 찾은 곳 안인가 — 한 메일함이면 그 메일함에 들었는지, 전체 메일함이면 휴지통 · 스팸메일함 밖의 메일함이 남았는지 + /// (다른 메일함으로 옮긴 메일은 남기고 메일함 표시만 바꾼다 — 웹과 같다). 메일함 역할을 아직 모르면 남긴다 + bool _inScope(List mailboxIds) { + final MailSearchState current = state; + if (!current.searchedAll) { + final String? scope = current.searchedMailboxId; + return scope != null && mailboxIds.contains(scope); + } + final MailboxesState mailboxes = ref.read(mailboxesProvider); + final String? trash = mailboxes.byRole(MailboxRole.trash)?.id; + final String? junk = mailboxes.byRole(MailboxRole.junk)?.id; + return mailboxIds.any((String id) => id != trash && id != junk); + } + + /// 받은 시각 내림차순에서 [item] 이 들 자리 — 같은 시각이면 그 뒤 + int _placeOf(List items, MailSearchItem item) { + final DateTime at = item.summary.receivedAt; + for (int index = 0; index < items.length; index++) { + if (items[index].summary.receivedAt.isBefore(at)) return index; + } + return items.length; + } + + // ── 붙잡힌 메일이 풀릴 때 ───────────────────────────── + + void _noteServerTime(DateTime? serverTime) { + if (serverTime != null) _serverOffset = serverTime.difference(_clock()); + } + + /// 서버 시계로 지금 — 받은 응답의 `Date` 로 잰 차이를 기기 시계에 더한다(없으면 기기 시계) + DateTime _serverNow() => _clock().add(_serverOffset); + + String _heldKey(String id, PendingSend pending) => + '$id\n${pending.sendAt.toIso8601String()}'; + + /// 결과의 붙잡힌 메일 가운데 가장 이른 보낼 시각 + 2초에 그 줄을 다시 받는다 — 시각이 지난 채 남으면 5초마다 3번 더 + /// (목록 컨트롤러와 같은 규칙). 다시 받는 중이면 끝난 뒤에 잡는다 + void _scheduleHeldCheck() { + _cancelHeldTimer(); + if (!ref.mounted || _heldRun != null) return; + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final DateTime now = _serverNow(); + DateTime? next; + for (final MailSearchItem item in current.items) { + final PendingSend? pending = item.summary.pendingSend; + if (pending == null) continue; + final DateTime due = pending.sendAt.add(kHeldReleaseGrace); + final DateTime at; + if (due.isAfter(now)) { + at = due; + } else { + final int rechecks = _heldRechecks[_heldKey(item.id, pending)] ?? 0; + if (rechecks > kHeldReleaseMaxRechecks) continue; + at = now.add(kHeldReleaseRecheck); + } + if (next == null || at.isBefore(next)) next = at; + } + if (next == null) return; + _heldTimer = Timer(next.difference(now), _onHeldDue); + } + + void _onHeldDue() { + _heldTimer = null; + if (!ref.mounted) return; + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final DateTime now = _serverNow(); + final List due = []; + for (final MailSearchItem item in current.items) { + final PendingSend? pending = item.summary.pendingSend; + if (pending == null || + pending.sendAt.add(kHeldReleaseGrace).isAfter(now)) { + continue; + } + final String key = _heldKey(item.id, pending); + final int rechecks = _heldRechecks[key] ?? 0; + if (rechecks > kHeldReleaseMaxRechecks) continue; + _heldRechecks[key] = rechecks + 1; + due.add(item.id); + } + if (due.isEmpty) { + _scheduleHeldCheck(); + return; + } + unawaited(_refreshHeld(due)); + } + + /// 붙잡힌 줄([ids])만 다시 받는다 — 표시 · 메일함 · 붙잡힘만 덮고(제목 · 미리 보기 · 강조는 그 검색 응답의 것 그대로), + /// 없어졌으면(`RES_001`) 뺀다. 실패는 알리지 않는다(다음 차례에 다시 본다). 그사이 읽기 화면이 결과를 바꿨으면 응답을 버린다(옛 상태) + Future _refreshHeld(List ids) async { + final Ref ref = this.ref; + final int seq = _seq; + final int generation = _localGeneration; + final int run = ++_heldRuns; + _heldRun = run; + final MailRepository repository = _repository; + final List updated = []; + final Set gone = {}; + for (final String id in ids) { + try { + final ({MessageDetail detail, DateTime? serverTime}) result = + await repository.getMessage(id); + if (!ref.mounted || seq != _seq) break; + _noteServerTime(result.serverTime); + updated.add(result.detail.summary); + } on DioException catch (error) { + if (apiErrorCodeOf(error) == 'RES_001') gone.add(id); + } + } + if (_heldRun == run) _heldRun = null; + if (!ref.mounted || seq != _seq) return; + if (generation == _localGeneration) { + _applyChanges(updated: updated, removedIds: gone, restore: false); + } + _scheduleHeldCheck(); + } + + void _cancelHeldTimer() { + _heldTimer?.cancel(); + _heldTimer = null; + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart new file mode 100644 index 0000000..d9e249a --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart @@ -0,0 +1,161 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:flutter/foundation.dart'; + +/// 검색 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 메일함에서 검색을 다시 열어도 앞 화면의 결과와 섞이지 않게 — +/// 쓰기 화면의 `ComposeArgs` 와 같은 결). [originMailboxId] 는 시작한 메일함(null 이면 받은편지함 — 메일함 목록의 기본 메일함) +@immutable +class MailSearchArgs { + const MailSearchArgs({required this.instance, this.originMailboxId}); + + final int instance; + final String? originMailboxId; + + @override + bool operator ==(Object other) => + other is MailSearchArgs && other.instance == instance; + + @override + int get hashCode => instance.hashCode; +} + +/// 검색 화면의 단계 +enum MailSearchPhase { + /// 찾기 전 — 안내 글 + idle, + + /// 검색어의 낱말이 한도를 넘었다 — 묻지 않고 안내만 + tooManyWords, + + /// 처음 찾는 중 — 아직 하나도 찾지 못했다(뼈대) + searching, + + /// 찾았다 — 결과 · 결과 없음 · 아직 못 찾음 + ready, + + /// 처음 찾지 못했다 — 받지 못함 + [다시 시도] + failed, +} + +/// 도는 이어 찾기 차례 +enum MailSearchFill { + none, + + /// 처음 찾을 때 · 목록 끝에 닿았을 때 · 끝의 [다시 시도] — 목록 끝에 도는 표시 + auto, + + /// [더 찾기]를 눌렀다 — 단추가 "찾는 중…"(다시 누르지 못한다) + more, +} + +const Object _unset = Object(); + +/// 검색 화면 하나의 상태 +@immutable +class MailSearchState { + const MailSearchState({ + this.phase = MailSearchPhase.idle, + this.allMailboxes = false, + this.query, + this.searchedAll = false, + this.searchedMailboxId, + this.items = const [], + this.pageInfo, + this.error, + this.refreshing = false, + this.fill = MailSearchFill.none, + this.partial = false, + this.loadMoreFailed = false, + this.indexStatus, + }); + + final MailSearchPhase phase; + + /// 찾을 곳 — 전체 메일함(참) · 시작한 메일함(거짓). 다음 검색이 쓴다 + final bool allMailboxes; + + /// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null. 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다 + final String? query; + + /// 보이는 결과를 전체 메일함에서 찾았다 — 줄마다 메일함이 다르다 + final bool searchedAll; + + /// 보이는 결과를 찾은 메일함 — 전체 메일함이면 null + final String? searchedMailboxId; + + /// 찾은 메일 — 받은 시각 내림차순(서버 차례) + final List items; + + /// 마지막으로 받은 쪽 — `total` 은 첫 쪽의 수(다음 쪽이 null 이면 남긴다 · 이 앱이 빼고 되살린 만큼 맞춘다) + final MailPageInfo? pageInfo; + + /// 처음 찾지 못한 까닭 (코드 · 추적 ID) + final ApiException? error; + + /// 보이는 채로 다시 찾는 중(같은 검색어로 다시 [검색] · 당겨서 새로고침) + final bool refreshing; + + /// 도는 이어 찾기 + final MailSearchFill fill; + + /// 한 차례(요청 5번)가 한 쪽을 채우지 못하고 끝났는데 더 찾을 것이 있다 — [더 찾기]로 사용자가 잇는다 + final bool partial; + + /// 이어 찾다 실패했다 · 같은 커서가 새 메일 없이 돌아왔다 — 목록 끝에 [다시 시도] + final bool loadMoreFailed; + + /// 지난 메일 검색 색인의 상태 — 받지 못했거나 모르면 null(안내 줄을 보이지 않는다) + final SearchIndexStatus? indexStatus; + + /// 전체 수 — 모르면 null(통 수를 보이지 않는다) + int? get total => pageInfo?.total; + + /// 서버에 더 있다 + bool get hasMore => pageInfo?.hasMore ?? false; + + /// [더 찾기]로만 잇는다 — 한 차례가 한 쪽을 못 채우고 멈췄거나, 불러온 결과를 모두 빼(읽기 화면에서 옮김 · 지움) 목록 끝이 없는데 서버에 더 있다 + bool get waitsForMore => hasMore && (partial || items.isEmpty); + + /// 결과 머리(개수 줄)를 보인다 — 찾는 중 · 찾은 뒤 + bool get showsHead => + phase == MailSearchPhase.searching || phase == MailSearchPhase.ready; + + MailSearchState copyWith({ + MailSearchPhase? phase, + bool? allMailboxes, + Object? query = _unset, + bool? searchedAll, + Object? searchedMailboxId = _unset, + List? items, + Object? pageInfo = _unset, + Object? error = _unset, + bool? refreshing, + MailSearchFill? fill, + bool? partial, + bool? loadMoreFailed, + Object? indexStatus = _unset, + }) => MailSearchState( + phase: phase ?? this.phase, + allMailboxes: allMailboxes ?? this.allMailboxes, + query: identical(query, _unset) ? this.query : query as String?, + searchedAll: searchedAll ?? this.searchedAll, + searchedMailboxId: identical(searchedMailboxId, _unset) + ? this.searchedMailboxId + : searchedMailboxId as String?, + items: items == null + ? this.items + : List.unmodifiable(items), + pageInfo: identical(pageInfo, _unset) + ? this.pageInfo + : pageInfo as MailPageInfo?, + error: identical(error, _unset) ? this.error : error as ApiException?, + refreshing: refreshing ?? this.refreshing, + fill: fill ?? this.fill, + partial: partial ?? this.partial, + loadMoreFailed: loadMoreFailed ?? this.loadMoreFailed, + indexStatus: identical(indexStatus, _unset) + ? this.indexStatus + : indexStatus as SearchIndexStatus?, + ); +} diff --git a/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart b/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart index eb583ca..12981c1 100644 --- a/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:isolate'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; @@ -10,12 +9,14 @@ import 'package:arasystem_app/core/storage/token_store.dart'; import 'package:arasystem_app/core/storage/token_store_provider.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; import 'package:arasystem_app/features/mail/domain/mail_html.dart'; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; @@ -24,15 +25,18 @@ import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_pr import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; -/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다 +/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다. +/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 없으면 빈 목록) typedef MailBodyBuilder = Future Function({ required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }); /// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf]) @@ -45,11 +49,13 @@ Future buildMailBody({ required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }) async { SanitizedMailBody build() => mailBodyFragment( htmlBody: htmlBody, textBody: textBody, imagesShown: imagesShown, + textHighlights: textHighlights, ); final MailBodyBuildPlace place = mailBodyBuildPlaceOf( htmlBody: htmlBody, @@ -59,14 +65,34 @@ Future buildMailBody({ return Isolate.run(build); } -/// 메일 한 통의 읽기 화면 — 주소의 메일 id 마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다. +/// 읽기 화면을 여는 값 — 주소의 메일 id 와, 검색 결과에서 열었으면 검색 강조 쿼리(라우트의 `extra` — 14단계 서버의 결과에서만) +@immutable +class MessageReaderArgs { + const MessageReaderArgs(this.messageId, {this.highlight}); + + final String messageId; + + /// 검색 강조 쿼리 — 있으면 그 쿼리로 받아 맞은 말을 칠한다([이미지 보기]도 같은 쿼리로) + final MessageHighlightQuery? highlight; + + @override + bool operator ==(Object other) => + other is MessageReaderArgs && + other.messageId == messageId && + other.highlight == highlight; + + @override + int get hashCode => Object.hash(messageId, highlight); +} + +/// 메일 한 통의 읽기 화면 — 여는 값([MessageReaderArgs])마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다. final NotifierProviderFamily< MessageReaderController, MessageReaderState, - String + MessageReaderArgs > messageReaderProvider = NotifierProvider.autoDispose - .family( + .family( MessageReaderController.new, ); @@ -85,11 +111,17 @@ messageReaderProvider = NotifierProvider.autoDispose /// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다. /// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게. /// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다. +/// - 검색 결과에서 열었으면(강조 쿼리 — 14단계) 그 쿼리로 받아 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다. 제목 강조는 그 응답의 제목 글과 함께 쥔다 — +/// 붙잡힌 메일을 다시 봐 요약을 바꿔도 그 글로 그린다(Dart 에는 NFC 가 없어 다른 응답의 글에 위치를 쓰지 않는다). +/// [이미지 보기]는 같은 쿼리로 다시 받아 본문 · 첨부 · 강조를 함께 바꾼다. 답장 · 전달은 쓰기 화면이 쿼리 없이 따로 받는다. class MessageReaderController extends Notifier { - MessageReaderController(this.messageId); + MessageReaderController(this.args); + + /// 여는 값 — 메일 id · 검색 강조 쿼리 + final MessageReaderArgs args; /// 주소의 메일 id - final String messageId; + String get messageId => args.messageId; int _seq = 0; int _bodySeq = 0; @@ -142,12 +174,13 @@ class MessageReaderController extends Notifier { final Ref ref = this.ref; try { final ({MessageDetail detail, DateTime? serverTime}) result = - await _repository.getMessage(messageId); + await _repository.getMessage(messageId, highlight: args.highlight); if (!ref.mounted || seq != _seq) return; _noteServerTime(result.serverTime); state = MessageReaderState( status: MessageReaderStatus.ready, detail: result.detail, + subjectHighlight: _subjectHighlightOf(result.detail), ); _scheduleHeldCheck(); unawaited(_buildBody(result.detail)); @@ -175,6 +208,15 @@ class MessageReaderController extends Notifier { if (serverTime != null) _serverOffset = serverTime.difference(_clock()); } + /// 강조를 받은 응답의 제목 강조 — 맞은 곳이 없거나(제목에서 맞지 않음) 제목이 비었으면 null + HighlightedLine? _subjectHighlightOf(MessageDetail detail) { + final List ranges = + detail.searchHighlight?.subject ?? const []; + final String subject = detail.summary.subject; + if (ranges.isEmpty || subject.isEmpty) return null; + return (text: subject, ranges: ranges); + } + DateTime _serverNow() => _clock().add(_serverOffset); void _setSummary(MessageSummary Function(MessageSummary current) change) { @@ -212,11 +254,14 @@ class MessageReaderController extends Notifier { bool alive() => ref.mounted && bodySeq == _bodySeq; final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider); SanitizedMailBody body; + final List textHighlights = + detail.searchHighlight?.textBody ?? const []; try { body = await buildBody( htmlBody: detail.htmlBody, textBody: detail.textBody, imagesShown: detail.imagesShown, + textHighlights: textHighlights, ); } on Object { // 정화가 뜻밖에 실패하면 서식 없이 글자 본문만 보인다 @@ -224,6 +269,7 @@ class MessageReaderController extends Notifier { htmlBody: null, textBody: detail.textBody, imagesShown: false, + textHighlights: textHighlights, ); } if (!alive()) return; @@ -309,6 +355,7 @@ class MessageReaderController extends Notifier { } /// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다). + /// 검색 결과에서 열었으면 같은 강조 쿼리로 받아 본문 · 첨부 · 제목의 강조도 그 응답의 것으로 바꾼다(계약 2절 "검색 — 14단계"). /// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다 Future showImages() async { final Ref ref = this.ref; @@ -327,12 +374,16 @@ class MessageReaderController extends Notifier { messageId, showImages: true, silent: false, + highlight: args.highlight, ); if (!ref.mounted || seq != _seq) return; final MessageDetail next = (state.detail ?? current).withBodyOf( result.detail, ); - state = state.copyWith(detail: next); + state = state.copyWith( + detail: next, + subjectHighlight: _subjectHighlightOf(result.detail), + ); await _buildBody(next); } on DioException { if (!ref.mounted || seq != _seq) return; diff --git a/flutter/lib/features/mail/presentation/providers/message_reader_state.dart b/flutter/lib/features/mail/presentation/providers/message_reader_state.dart index 5f9f064..674b025 100644 --- a/flutter/lib/features/mail/presentation/providers/message_reader_state.dart +++ b/flutter/lib/features/mail/presentation/providers/message_reader_state.dart @@ -1,7 +1,10 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:flutter/foundation.dart'; +const Object _unset = Object(); + /// 읽기 화면의 상태 enum MessageReaderStatus { /// 메일을 받는 중 @@ -37,6 +40,7 @@ class MessageReaderState { this.bodyOmitted = false, this.imagesLoading = false, this.busy, + this.subjectHighlight, }); final MessageReaderStatus status; @@ -60,6 +64,10 @@ class MessageReaderState { /// 하는 중인 일 — 없으면 null final MessageReaderBusy? busy; + /// 검색 결과에서 연 메일의 제목 강조 — 강조를 받은 응답의 제목 글과 그 맞은 곳(그 뒤에 요약을 다시 받아도 이 글로 그린다). + /// 강조가 없으면 null(머리는 지금 요약의 제목을 그대로 보인다) + final HighlightedLine? subjectHighlight; + /// 아직 보내지 않은 내 메일(보내기 취소를 기다림 · 예약) — 취소로만 보낸편지함을 떠난다 bool get isHeld => detail?.summary.isHeld ?? false; @@ -70,6 +78,7 @@ class MessageReaderState { bool? imagesLoading, MessageReaderBusy? busy, bool clearBusy = false, + Object? subjectHighlight = _unset, }) => MessageReaderState( status: status, detail: detail ?? this.detail, @@ -78,5 +87,8 @@ class MessageReaderState { bodyOmitted: bodyOmitted ?? this.bodyOmitted, imagesLoading: imagesLoading ?? this.imagesLoading, busy: clearBusy ? null : (busy ?? this.busy), + subjectHighlight: identical(subjectHighlight, _unset) + ? this.subjectHighlight + : subjectHighlight as HighlightedLine?, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart b/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart new file mode 100644 index 0000000..90f0c0f --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart @@ -0,0 +1,55 @@ +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter/material.dart'; + +/// 검색에서 맞은 말을 칠한 글 — 글 조각(`TextSpan`)으로 그린다(웹 `HighlightedText.vue` — v-html 없이 `` 조각). +/// +/// - 맞은 말은 노랑 바탕(`--color-search-mark` #FCE7A4)만 칠하고 글자색은 그대로다. 회색 글(목록의 미리 보기)은 강조 안만 [markColor] 로 진하게 한다 +/// (보드 `App1Search` — 노랑 위 #667085 는 4.05:1 이라 #4A5363 6.3:1 로). +/// - 웹의 모서리 2 · 좌우 1px 은 글 조각에 줄 수 없어 뺐다(본문 WebView 의 `mark` 에는 있다). +/// - 위치는 [text] 의 UTF-16 [시작, 끝) — 글 밖 · 거꾸로 된 위치는 버리고, 짝 글자를 가르지 않는다([highlightSegments]). +/// - 화면 낭독기는 글만 읽는다(강조는 읽지 않는다). +class HighlightedText extends StatelessWidget { + const HighlightedText( + this.text, { + required this.ranges, + this.style, + this.markColor, + this.maxLines, + this.overflow, + super.key, + }); + + final String text; + final List ranges; + final TextStyle? style; + + /// 맞은 말의 글자색 — 없으면 둘레 글과 같다 + final Color? markColor; + final int? maxLines; + final TextOverflow? overflow; + + @override + Widget build(BuildContext context) { + final List segments = highlightSegments(text, ranges); + if (!hasMarkedSegment(segments)) { + return Text(text, style: style, maxLines: maxLines, overflow: overflow); + } + final AppExtraColors extra = Theme.of(context).extension()!; + final TextStyle mark = TextStyle( + backgroundColor: extra.searchMark, + color: markColor, + ); + return Text.rich( + TextSpan( + children: [ + for (final HighlightSegment segment in segments) + TextSpan(text: segment.text, style: segment.mark ? mark : null), + ], + ), + style: style, + maxLines: maxLines, + overflow: overflow, + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart index 107e311..9f9ccd9 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -40,7 +40,7 @@ class MailListEmpty extends StatelessWidget { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final AppExtraColors extra = theme.extension()!; - return _StateLayout( + return MailStateLayout( icon: mailboxIconOf(role), iconColor: extra.textMuted, iconBackground: theme.colorScheme.surfaceContainerHigh, @@ -59,13 +59,15 @@ class MailListEmpty extends StatelessWidget { } /// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다) -/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]) +/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]), +/// 검색 화면도 쓴다(찾던 메일함이 지워졌으면 [전체 메일함에서 찾기] — 제목은 보드 `App1SearchStates` 대로 작게) class MailListError extends StatelessWidget { const MailListError({ required this.error, required this.onRetry, this.onLeave, this.leaveLabel = MailListTexts.toInbox, + this.titleStyle, super.key, }); @@ -78,6 +80,9 @@ class MailListError extends StatelessWidget { /// [onLeave] 단추의 이름 final String leaveLabel; + /// 제목 글자 — 없으면 [MailStateLayout] 의 기본(목록 보드의 크기) + final TextStyle? titleStyle; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -85,11 +90,12 @@ class MailListError extends StatelessWidget { final VoidCallback? leave = onLeave; final bool notFound = error.code == 'RES_001' && leave != null; final String? trace = traceCodeOf(error.traceId); - return _StateLayout( + return MailStateLayout( icon: Icons.error_outline, iconColor: theme.colorScheme.error, iconBackground: theme.colorScheme.errorContainer, title: error.message, + titleStyle: titleStyle, children: [ if (trace != null) Text( @@ -114,13 +120,17 @@ String? traceCodeOf(String? traceId) { : value; } -class _StateLayout extends StatelessWidget { - const _StateLayout({ +/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다. +/// 목록의 빈 메일함 · 받지 못함, 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음이 쓴다 +class MailStateLayout extends StatelessWidget { + const MailStateLayout({ required this.icon, required this.iconColor, required this.iconBackground, required this.title, required this.children, + this.titleStyle, + super.key, }); final IconData icon; @@ -129,6 +139,9 @@ class _StateLayout extends StatelessWidget { final String title; final List children; + /// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드는 15(`titleSmall`) + final TextStyle? titleStyle; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -161,9 +174,11 @@ class _StateLayout extends StatelessWidget { header: true, child: KeepAllText( title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: AppTypography.semiBold, - ), + style: + titleStyle ?? + theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.semiBold, + ), ), ), ), @@ -176,15 +191,18 @@ class _StateLayout extends StatelessWidget { } } -/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중") +/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중" — 검색은 "찾는 중") class MailListSkeleton extends StatelessWidget { - const MailListSkeleton({super.key}); + const MailListSkeleton({this.label = MailListTexts.loading, super.key}); + + /// 화면 낭독기 글 + final String label; @override Widget build(BuildContext context) { final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest; return Semantics( - label: MailListTexts.loading, + label: label, liveRegion: true, child: ExcludeSemantics( child: Column( @@ -313,6 +331,7 @@ class MailListFooter extends StatefulWidget { required this.failed, required this.onReached, required this.onRetry, + this.loadingLabel = MailListTexts.loadingMore, super.key, }); @@ -321,6 +340,9 @@ class MailListFooter extends StatefulWidget { final VoidCallback onReached; final VoidCallback onRetry; + /// 도는 표시의 화면 낭독기 글 — 검색 결과는 "찾는 중" + final String loadingLabel; + @override State createState() => _MailListFooterState(); } @@ -356,7 +378,7 @@ class _MailListFooterState extends State { child: const Text(MailListTexts.retry), ) : Semantics( - label: MailListTexts.loadingMore, + label: widget.loadingLabel, child: const SizedBox.square( dimension: AppSize.iconLarge, child: CircularProgressIndicator( diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart new file mode 100644 index 0000000..6d15ec7 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart @@ -0,0 +1,426 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/utf16_length.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) (보드 `App1Search`, 높이 64). +/// +/// 칸은 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 글 자판(메일 주소 자판은 한글을 막는다) · 자동 고침 끔 · 추천 켬 · +/// 둥근 따옴표 끔(큰따옴표가 둥근 따옴표가 되면 구절이 아니라 낱말로 찾는다). UTF-16 200자까지(서버와 같은 단위). +/// 붙여 넣은 줄바꿈은 띄어쓰기로 바꾼다(한 줄 칸은 Flutter 가 줄바꿈을 지워 낱말이 붙는다) — 긴 검색어는 두 줄까지 늘어난다. +/// [검색] 뒤 초점은 부르는 쪽이 정한다(빈 칸이면 자판을 그대로 둔다). +class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { + const MailSearchBar({ + required this.controller, + required this.focusNode, + required this.onSubmitted, + required this.onClear, + required this.onClose, + super.key, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onSubmitted; + final VoidCallback onClear; + final VoidCallback onClose; + + @override + Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodyLarge; + return AppBar( + toolbarHeight: AppSize.appBarHeight, + automaticallyImplyLeading: false, + leadingWidth: AppSize.touchMin + AppSpacing.s8, + titleSpacing: AppSpacing.s4, + shape: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge), + tooltip: MailSearchTexts.close, + color: extra.textStrong, + onPressed: onClose, + ), + // 칸의 이름은 이 묶음이 한 번 읽는다 — 보이는 안내 글은 낭독에서 뺀다(빈 칸에서 "메일 검색"을 두 번 읽지 않게 · + // 글을 쓴 뒤에도 이름이 남게) + title: Semantics( + label: MailSearchTexts.box, + child: TextField( + key: const ValueKey('mail-search-field'), + controller: controller, + focusNode: focusNode, + autofocus: true, + minLines: 1, + maxLines: 2, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: true, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + inputFormatters: const [ + _LineBreaksToSpaces(), + Utf16LengthLimitFormatter(kMailSearchMaxLength), + ], + style: style, + decoration: InputDecoration.collapsed( + hintText: null, + hint: ExcludeSemantics( + child: Text( + MailSearchTexts.box, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style?.copyWith(color: theme.colorScheme.outline), + ), + ), + ), + onSubmitted: onSubmitted, + // 기본은 [검색] 뒤 초점을 놓는다 — 빈 칸이면 자판을 그대로 둬야 해서 부르는 쪽(onSubmitted)이 정한다 + onEditingComplete: () {}, + ), + ), + actionsPadding: const EdgeInsets.only(right: AppSpacing.s4), + actions: [ + ValueListenableBuilder( + valueListenable: controller, + builder: (BuildContext context, TextEditingValue value, Widget? _) => + value.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.close, size: AppSize.iconToolbar), + tooltip: MailSearchTexts.clear, + color: extra.textStrong, + onPressed: onClear, + ), + ), + ], + ); + } +} + +/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결) +class _LineBreaksToSpaces extends TextInputFormatter { + const _LineBreaksToSpaces(); + + static final RegExp _lineBreaks = RegExp(r'\r\n|\r|\n'); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (!newValue.text.contains(_lineBreaks)) return newValue; + final String text = newValue.text.replaceAll(_lineBreaks, ' '); + final int cursor = newValue.selection.extentOffset.clamp( + 0, + newValue.text.length, + ); + final String before = newValue.text.substring(0, cursor); + final int shift = + before.length - before.replaceAll(_lineBreaks, ' ').length; + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: cursor - shift), + ); + } +} + +/// 찾을 곳 줄 — "찾을 곳" + 두 칸 전환 [{시작한 메일함} | 전체 메일함] (보드 `App1Search` — 높이 56 · 전환은 보이는 높이 40 · 누르는 곳 48). +/// 고른 칸은 옅은 청록 바탕 · 굵은 글(✓ 없음). 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". +/// 화면 낭독기는 "찾을 곳" 묶음 안의 두 단추(고름 상태)로 읽는다 +class MailSearchScopeBar extends StatelessWidget { + const MailSearchScopeBar({ + required this.originName, + required this.allMailboxes, + required this.onChanged, + super.key, + }); + + /// 시작한 메일함의 이름 + final String originName; + + /// 전체 메일함을 골랐다 + final bool allMailboxes; + + /// 고른 곳 — 전체 메일함이면 참 + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + border: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.searchScopeRowHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s16), + child: Row( + children: [ + ExcludeSemantics( + child: Text( + MailSearchTexts.scopeLabel, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: AppSpacing.s12), + Expanded( + child: Semantics( + container: true, + explicitChildNodes: true, + label: MailSearchTexts.scopeLabel, + child: Row( + children: [ + Expanded( + child: _ScopeToggle( + label: originName, + selected: !allMailboxes, + first: true, + onTap: () => onChanged(false), + ), + ), + Expanded( + child: _ScopeToggle( + label: MailSearchTexts.scopeAll, + selected: allMailboxes, + first: false, + onTap: () => onChanged(true), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + if (allMailboxes) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + 0, + AppSpacing.s16, + AppSpacing.s12, + ), + child: KeepAllText( + MailSearchTexts.scopeAllHint, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ), + ), + ), + ], + ), + ); + } +} + +/// 전환 칸 하나 — 누르는 곳 48 안의 보이는 40 칸. 두 칸은 가운데 선 하나를 나눠 쓴다(뒤 칸을 선 굵기만큼 앞으로 겹친다) +class _ScopeToggle extends StatelessWidget { + const _ScopeToggle({ + required this.label, + required this.selected, + required this.first, + required this.onTap, + }); + + final String label; + final bool selected; + + /// 앞 칸 — 왼쪽이 둥글다(뒤 칸은 오른쪽) + final bool first; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + const Radius round = Radius.circular(AppSize.searchToggleHeight / 2); + final BorderRadius radius = first + ? const BorderRadius.horizontal(left: round) + : const BorderRadius.horizontal(right: round); + final Widget toggle = Semantics( + container: true, + button: true, + selected: selected, + label: label, + onTap: onTap, + child: InkWell( + onTap: onTap, + excludeFromSemantics: true, + customBorder: RoundedRectangleBorder(borderRadius: radius), + child: SizedBox( + height: AppSize.touchMin, + child: Center( + child: Container( + height: AppSize.searchToggleHeight, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s12), + decoration: BoxDecoration( + color: selected ? scheme.primaryContainer : scheme.surface, + border: Border.all( + color: extra.borderStrong, + width: AppSize.borderWidth, + ), + borderRadius: radius, + ), + child: ExcludeSemantics( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: selected + ? AppTypography.bold + : AppTypography.medium, + color: selected + ? scheme.onPrimaryContainer + : extra.textStrong, + ), + ), + ), + ), + ), + ), + ), + ); + if (first) return toggle; + return Transform.translate( + offset: const Offset(-AppSize.borderWidth, 0), + child: toggle, + ); + } +} + +/// 결과 머리 — "‘계약’ · 4통"(높이 36 — 큰 글자면 글에 맞춰 늘어난다). 결과가 오면 화면 낭독기가 읽는다 +class MailSearchCountLine extends StatelessWidget { + const MailSearchCountLine(this.text, {super.key}); + + final String text; + + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + liveRegion: true, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.searchCountLineHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: AppSpacing.s4, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + fontFeatures: _tabular, + ), + ), + ), + ), + ), + ); + } +} + +/// 결과 머리 아래의 안내 줄 — 옅은 청록 바탕 · 청록 정보 아이콘 (검색 준비 중 · 한 글자 안내 — 보드 `App1Search`) +class MailSearchNoticeLine extends StatelessWidget { + const MailSearchNoticeLine(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + liveRegion: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + border: Border( + bottom: BorderSide( + color: extra.brandTintBorder, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: AppSpacing.s10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Icon( + Icons.info_outline, + size: AppSize.iconTiny, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.s8), + Expanded( + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart new file mode 100644 index 0000000..eb91f13 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -0,0 +1,226 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 검색 화면의 상태 글 — 찾기 전 · 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음 · 목록 끝의 [더 찾기] (보드 `App1Search` · `App1SearchStates`). +/// 가운데 상태는 목록과 같은 틀([MailStateLayout])이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다 +TextStyle? _stateTitleStyle(ThemeData theme) => + theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold); + +TextStyle? _stateHintStyle(ThemeData theme) => + theme.textTheme.bodySmall?.copyWith( + color: theme.extension()!.textMuted, + height: AppTypography.lineHeightRelaxed, + ); + +/// 찾기 전 — 무엇을 찾는지 한 줄 안내(왼쪽 맞춤 · 위 16) +class MailSearchIntro extends StatelessWidget { + const MailSearchIntro({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s16, + AppSpacing.s16, + 0, + ), + child: KeepAllText(MailSearchTexts.intro, style: _stateHintStyle(theme)), + ); + } +} + +/// 결과 없음 — "‘해지 통보’에 맞는 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기] +class MailSearchEmpty extends StatelessWidget { + const MailSearchEmpty({ + required this.query, + required this.indexing, + required this.onSearchAll, + super.key, + }); + + final String query; + + /// 지난 메일을 검색할 수 있게 준비하는 중 — 그동안 찾지 못하는 말의 예를 더한다 + final bool indexing; + + /// [전체 메일함에서 찾기] — 전체 메일함에서 찾았으면 null(단추 없음) + final VoidCallback? onSearchAll; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final VoidCallback? searchAll = onSearchAll; + return MailStateLayout( + icon: Icons.search, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.emptyTitle(query), + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)), + if (indexing) ...[ + const SizedBox(height: AppSpacing.s8), + KeepAllText( + MailSearchTexts.wordLimitHint, + style: _stateHintStyle(theme), + ), + ], + if (searchAll != null) ...[ + const SizedBox(height: AppSpacing.s8), + TextButton( + onPressed: searchAll, + child: const Text(MailSearchTexts.searchAll), + ), + ], + ], + ); + } +} + +/// 아직 못 찾음 — 한 차례(요청 5번)가 하나도 찾지 못하고 끝났는데 더 찾을 것이 있다: 안내 · [더 찾기](누른 동안 "찾는 중…") +class MailSearchPartial extends StatelessWidget { + const MailSearchPartial({ + required this.busy, + required this.onMore, + super.key, + }); + + /// [더 찾기]를 눌러 찾는 중 + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MailStateLayout( + icon: Icons.search, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.partialTitle, + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText(MailSearchTexts.partialHint, style: _stateHintStyle(theme)), + const SizedBox(height: AppSpacing.s8), + MailSearchMoreButton(busy: busy, onMore: onMore), + ], + ); + } +} + +/// 찾을 말이 너무 많음 — 묻지 않고 안내만(낱말을 줄이라고) +class MailSearchTooManyWords extends StatelessWidget { + const MailSearchTooManyWords({required this.maxWords, super.key}); + + final int maxWords; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MailStateLayout( + icon: Icons.error_outline, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.tooManyWordsTitle, + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText( + MailSearchTexts.tooManyWordsHint(maxWords), + style: _stateHintStyle(theme), + ), + ], + ); + } +} + +/// 목록 끝 — 한 차례가 한 쪽을 못 채우고 멈췄다: 안내 한 줄 + [더 찾기] (보드 `App1SearchStates` 「목록 끝 · 더 찾기」 — 최소 높이 64) +class MailSearchMoreLine extends StatelessWidget { + const MailSearchMoreLine({ + required this.busy, + required this.onMore, + super.key, + }); + + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.touchMin + AppSpacing.s16, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s8, + AppSpacing.s8, + AppSpacing.s8, + ), + child: Row( + children: [ + Expanded( + child: KeepAllText( + MailSearchTexts.partialHint, + style: _stateHintStyle(theme), + ), + ), + const SizedBox(width: AppSpacing.s8), + MailSearchMoreButton(busy: busy, onMore: onMore), + ], + ), + ), + ); + } +} + +/// [더 찾기] — 누른 동안은 "찾는 중…"(회색)이고 다시 누르지 못한다. 초점은 지킨다(꺼진 단추로 바꾸지 않고 누름만 받지 않는다 — +/// 화면 낭독기는 꺼진 것으로 읽는다) +class MailSearchMoreButton extends StatelessWidget { + const MailSearchMoreButton({ + required this.busy, + required this.onMore, + super.key, + }); + + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + final String label = busy ? MailSearchTexts.moreBusy : MailSearchTexts.more; + return Semantics( + container: true, + button: true, + enabled: !busy, + label: label, + onTap: busy ? null : onMore, + child: ExcludeSemantics( + child: TextButton( + // 누른 동안도 단추는 그대로 둔다(초점이 옮겨 가지 않게) — 누름만 받지 않는다 + onPressed: busy ? _ignore : onMore, + style: busy + ? TextButton.styleFrom( + foregroundColor: extra.textMuted, + overlayColor: Colors.transparent, + ) + : null, + child: Text(label), + ), + ), + ); + } + + static void _ignore() {} +} diff --git a/flutter/lib/features/mail/presentation/widgets/message_row.dart b/flutter/lib/features/mail/presentation/widgets/message_row.dart index 1c757af..f1d4b6d 100644 --- a/flutter/lib/features/mail/presentation/widgets/message_row.dart +++ b/flutter/lib/features/mail/presentation/widgets/message_row.dart @@ -3,20 +3,26 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:flutter/material.dart'; +/// 전체 메일함 검색 결과 줄의 메일함 이름이 쓸 수 있는 폭 — 글 줄 폭의 이만큼(보드 `App1Search` 의 124 / 390 화면), 넘으면 말줄임 +const double _rowMailboxMaxFraction = 0.4; + /// 목록 줄 하나 — 보드 `App1MailList` 의 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 한 줄. /// /// - 안 읽음은 굵은 글자로만 가른다(뱃지 없음 — 화면 낭독기는 앞에 "안 읽음"). /// - 사진을 누르면 고르기 · 풀기, 줄을 길게 누르면 고르기. 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다). /// - 아직 보내지 않은 메일은 고를 수 없다 — 사진 단추는 꺼진 것으로 읽히고, 눌러도 까닭만 알린다(부르는 쪽이). +/// - 검색 결과(보드 `App1Search`)는 고르지 않는다 — [onAvatarTap] · [onLongPress] 가 없으면 사진은 단추가 아니다. +/// 제목 · 미리 보기의 맞은 말은 노랑 바탕이고, 전체 메일함 결과는 시각 앞에 메일함 이름(시각과 같은 글자)을 둔다. class MessageRow extends StatelessWidget { const MessageRow({ required this.view, required this.selected, required this.onTap, - required this.onLongPress, - required this.onAvatarTap, + this.onLongPress, + this.onAvatarTap, super.key, }); @@ -25,58 +31,90 @@ class MessageRow extends StatelessWidget { /// 고른 줄 — 바탕이 옅은 청록이고 사진 자리에 체크 final bool selected; final VoidCallback onTap; - final VoidCallback onLongPress; - final VoidCallback onAvatarTap; + + /// 길게 누르기(고르기) — 없으면 길게 눌러도 아무 일 없다(검색 결과) + final VoidCallback? onLongPress; + + /// 사진 누르기(고르기 · 풀기) — 없으면 사진은 단추가 아니다(검색 결과) + final VoidCallback? onAvatarTap; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final AppExtraColors extra = theme.extension()!; - return Semantics( - container: true, - selected: selected, - label: view.semanticsLabel, - child: Material( - color: selected ? extra.rowChecked : theme.colorScheme.surface, - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: theme.colorScheme.surfaceContainerHigh, - width: AppSize.borderWidth, - ), - ), + final Widget ink = InkWell( + onTap: onTap, + onLongPress: onLongPress, + // 화면 낭독기의 누르기 · 길게 누르기는 위 Semantics 가 맡는다(받지 않는 길게 누르기는 알리지 않는다) + excludeFromSemantics: true, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHigh, + width: AppSize.borderWidth, ), - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.s8, - AppSpacing.s10, - AppSpacing.s16, - AppSpacing.s10, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MailAvatarButton( - initial: view.initial, - selected: selected, - held: view.held, - label: view.selectLabel, - onTap: onAvatarTap, + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s8, + AppSpacing.s10, + AppSpacing.s16, + AppSpacing.s10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (onAvatarTap case final VoidCallback onAvatar) + MailAvatarButton( + initial: view.initial, + selected: selected, + held: view.held, + label: view.selectLabel, + onTap: onAvatar, + ) + else + ExcludeSemantics( + child: SizedBox.square( + dimension: AppSize.touchMin, + child: Center( + child: _AvatarFace( + initial: view.initial, + selected: selected, + ), + ), ), - const SizedBox(width: AppSpacing.s6), - Expanded(child: ExcludeSemantics(child: _RowTexts(view))), - ], - ), - ), + ), + const SizedBox(width: AppSpacing.s6), + Expanded(child: ExcludeSemantics(child: _RowTexts(view))), + ], ), ), ), ); + return Semantics( + container: true, + selected: selected, + label: view.semanticsLabel, + onTap: onTap, + onLongPress: onLongPress, + child: Material( + color: selected ? extra.rowChecked : theme.colorScheme.surface, + child: onLongPress == null + // 고르지 않는 줄(검색 결과) — 길게 누르기를 받아 둔다. 받지 않으면 손을 뗄 때 누르기로 읽혀 메일이 열린다 + // (검색 1차: 길게 눌러도 아무 일 없다). InkWell 의 길게 누르기는 진동하므로 쓰지 않는다 + ? GestureDetector( + onLongPress: _ignoreLongPress, + excludeFromSemantics: true, + child: ink, + ) + : ink, + ), + ); } + + static void _ignoreLongPress() {} } /// 동그란 사진 — 누르는 곳 48 안의 40 원(머리글자 · 고르면 청록 원에 체크). @@ -209,6 +247,11 @@ class _RowTexts extends StatelessWidget { final TextStyle? previewStyle = text.bodySmall?.copyWith( color: extra.textMuted, ); + final TextStyle? mailboxStyle = text.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: extra.textMuted, + ); + final String? mailbox = view.mailboxName; return Padding( padding: const EdgeInsets.only(top: AppSpacing.s2), @@ -216,27 +259,44 @@ class _RowTexts extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Expanded( - child: Text( - view.who, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: whoStyle, + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + view.who, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: whoStyle, + ), ), - ), - const SizedBox(width: AppSpacing.s8), - Text(view.time, maxLines: 1, style: timeStyle), - ], + const SizedBox(width: AppSpacing.s8), + if (mailbox != null) ...[ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * _rowMailboxMaxFraction, + ), + child: Text( + MailSearchTexts.rowMailbox(mailbox), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mailboxStyle, + ), + ), + const SizedBox(width: AppSpacing.s4), + ], + Text(view.time, maxLines: 1, style: timeStyle), + ], + ), ), Row( children: [ Expanded( - child: Text( + child: HighlightedText( view.subject, + ranges: view.subjectHighlights, maxLines: 1, overflow: TextOverflow.ellipsis, style: subjectStyle, @@ -264,8 +324,11 @@ class _RowTexts extends StatelessWidget { ), ], ), - Text( + HighlightedText( view.preview, + ranges: view.previewHighlights, + // 회색 글 위 노랑은 대비가 낮아 강조 안만 진하게(웹 `--color-text-secondary` — 보드 `App1Search`) + markColor: scheme.onSurfaceVariant, maxLines: 1, overflow: TextOverflow.ellipsis, style: previewStyle, diff --git a/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart b/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart index 29bda44..0727b6f 100644 --- a/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart +++ b/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart @@ -3,8 +3,10 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/core/utils/file_size.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; import 'package:flutter/material.dart'; @@ -12,6 +14,7 @@ import 'package:flutter/material.dart'; /// /// 받는 동안은 크기 자리에 진행 막대 · "받는 중 64%" 이고 단추가 [받기 취소](✕)다. /// 대용량 첨부(11단계)는 첫 판에서 받지 않는다 — 이름 · 크기와 "대용량 첨부"만 보이고, 목록 아래에 받는 길을 알린다. +/// 검색 결과에서 연 메일은 첨부 이름의 맞은 말을 칠한다([nameHighlights] — 그 응답의 첨부 이름 기준). class ReaderAttachments extends StatelessWidget { const ReaderAttachments({ required this.attachments, @@ -19,6 +22,7 @@ class ReaderAttachments extends StatelessWidget { required this.downloads, required this.onDownload, required this.onCancel, + this.nameHighlights = const >{}, super.key, }); @@ -31,6 +35,9 @@ class ReaderAttachments extends StatelessWidget { final ValueChanged onDownload; final ValueChanged onCancel; + /// 첨부 이름의 검색에서 맞은 곳 — 첨부 id 마다(맞은 첨부만) + final Map> nameHighlights; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -74,6 +81,8 @@ class ReaderAttachments extends StatelessWidget { _AttachmentRow( key: ValueKey('attachment-${attachment.id}'), attachment: attachment, + nameHighlights: + nameHighlights[attachment.id] ?? const [], download: downloads[attachment.id], onDownload: () => onDownload(attachment), onCancel: () => onCancel(attachment), @@ -117,6 +126,7 @@ class ReaderAttachments extends StatelessWidget { class _AttachmentRow extends StatelessWidget { const _AttachmentRow({ required this.attachment, + required this.nameHighlights, required this.download, required this.onDownload, required this.onCancel, @@ -125,6 +135,9 @@ class _AttachmentRow extends StatelessWidget { final MessageAttachment attachment; + /// 이름 안의 검색에서 맞은 곳 + final List nameHighlights; + /// 받는 중이면 그 진행 — 아니면 null final AttachmentDownload? download; final VoidCallback onDownload; @@ -181,6 +194,7 @@ class _AttachmentRow extends StatelessWidget { return _FileRow( icon: Icons.insert_drive_file_outlined, name: attachment.name, + nameHighlights: nameHighlights, below: below, trailing: progress == null ? IconButton( @@ -208,11 +222,15 @@ class _FileRow extends StatelessWidget { required this.icon, required this.name, required this.below, + this.nameHighlights = const [], this.trailing, }); final IconData icon; final String name; + + /// [name] 안의 검색에서 맞은 곳 + final List nameHighlights; final Widget below; final Widget? trailing; @@ -261,8 +279,9 @@ class _FileRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( + HighlightedText( name, + ranges: nameHighlights, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( diff --git a/flutter/lib/features/mail/presentation/widgets/reader_header.dart b/flutter/lib/features/mail/presentation/widgets/reader_header.dart index 1d18fb2..e1e4055 100644 --- a/flutter/lib/features/mail/presentation/widgets/reader_header.dart +++ b/flutter/lib/features/mail/presentation/widgets/reader_header.dart @@ -3,19 +3,24 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:flutter/material.dart'; /// 읽기 화면의 머리 — 제목 · 보낸 사람(동그란 사진 40) · 시각 · "받는 사람 나 외 1명"(누르면 펼침) (보드 `App1Reader`). /// /// 펼치면 회색 상자에 보낸 사람 · 받는 사람 · 참조 · 숨은참조(내가 보낸 메일) · 날짜를 칸마다 이름 · 주소로 보인다. +/// 검색 결과에서 연 메일(보드 `App1SearchStates` 「결과에서 연 메일」)은 제목의 맞은 말을 칠하고, 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. class ReaderHeader extends StatefulWidget { const ReaderHeader({ required this.detail, required this.myEmail, required this.now, + this.subjectHighlight, + this.mailboxName, super.key, }); @@ -27,6 +32,12 @@ class ReaderHeader extends StatefulWidget { /// 시각 표기의 "오늘 · 어제"를 가르는 지금 final DateTime now; + /// 검색 강조 — 강조를 받은 응답의 제목 글과 맞은 곳. 있으면 이 글로 그린다 + final HighlightedLine? subjectHighlight; + + /// 검색 결과에서 연 메일의 메일함 이름 — "메일함 받은편지함" 줄(그 밖에는 null) + final String? mailboxName; + @override State createState() => _ReaderHeaderState(); } @@ -38,9 +49,12 @@ class _ReaderHeaderState extends State { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final MessageDetail detail = widget.detail; - final String subject = detail.summary.subject.isEmpty - ? MailReaderTexts.noSubject - : detail.summary.subject; + final HighlightedLine? highlight = widget.subjectHighlight; + final String subject = + highlight?.text ?? + (detail.summary.subject.isEmpty + ? MailReaderTexts.noSubject + : detail.summary.subject); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, @@ -54,8 +68,9 @@ class _ReaderHeaderState extends State { ), child: Semantics( header: true, - child: Text( + child: HighlightedText( subject, + ranges: highlight?.ranges ?? const [], style: theme.textTheme.titleLarge?.copyWith( fontWeight: AppTypography.semiBold, ), @@ -73,6 +88,7 @@ class _ReaderHeaderState extends State { detail: detail, myEmail: widget.myEmail, now: widget.now, + mailboxName: widget.mailboxName, expanded: _expanded, onToggle: () => setState(() => _expanded = !_expanded), ), @@ -88,6 +104,7 @@ class _SenderRow extends StatelessWidget { required this.detail, required this.myEmail, required this.now, + required this.mailboxName, required this.expanded, required this.onToggle, }); @@ -95,6 +112,7 @@ class _SenderRow extends StatelessWidget { final MessageDetail detail; final String? myEmail; final DateTime now; + final String? mailboxName; final bool expanded; final VoidCallback onToggle; @@ -183,6 +201,7 @@ class _SenderRow extends StatelessWidget { ), ), ), + if (mailboxName case final String name) _MailboxLine(name: name), ], ), ), @@ -191,6 +210,42 @@ class _SenderRow extends StatelessWidget { } } +/// 검색 결과에서 연 메일의 "메일함 받은편지함" 줄 — 이름 칸은 흐리게(웹 3단계 `mailboxLine` · 보드 `App1SearchStates`) +class _MailboxLine extends StatelessWidget { + const _MailboxLine({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MergeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + MailSearchTexts.mailboxLine, + style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted), + ), + const SizedBox(width: AppSpacing.s8), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ); + } +} + class _Avatar extends StatelessWidget { const _Avatar({required this.initial}); diff --git a/flutter/test/core/people/recipient_match_test.dart b/flutter/test/core/people/recipient_match_test.dart index a9bcaed..aa66b0d 100644 --- a/flutter/test/core/people/recipient_match_test.dart +++ b/flutter/test/core/people/recipient_match_test.dart @@ -3,6 +3,7 @@ import 'package:arasystem_app/core/people/people_texts.dart'; import 'package:arasystem_app/core/people/person_address.dart'; import 'package:arasystem_app/core/people/recipient_match.dart'; import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/js_case.dart'; import 'package:flutter_test/flutter_test.dart'; import 'recipient_match_samples.dart'; diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index 5b2253f..4f320ce 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; @@ -467,6 +468,7 @@ void main() { testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async { h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); await h.pump(tester); await longPress(tester, '제목 m1'); await tester.tap(find.byTooltip(MailBulkTexts.delete)); @@ -481,11 +483,12 @@ void main() { await elapse(tester, const Duration(seconds: 1)); expect(find.byType(MailToastCard), findsNothing); - await tester.tap(find.byTooltip(MailListTexts.search)); + // 단추 없는 알림 — 고를 수 없는 메일의 까닭 + await tester.tap(avatarOf(_heldSubject)); await tester.pumpAndSettle(); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); await elapse(tester, kMailToastDuration - const Duration(seconds: 1)); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); await elapse(tester, const Duration(seconds: 1)); expect(find.byType(SnackBar), findsNothing); }); @@ -494,16 +497,17 @@ void main() { WidgetTester tester, ) async { h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); await h.pump(tester); await longPress(tester, '제목 m1'); await tester.tap(find.byTooltip(MailBulkTexts.delete)); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.tap(avatarOf(_heldSubject)); await tester.pumpAndSettle(); expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); final Rect snack = tester.getRect(find.byType(SnackBar)); final Rect compose = tester.getRect( find.widgetWithText(FloatingActionButton, MailListTexts.compose), @@ -841,7 +845,7 @@ void main() { expect(find.text('거래처'), findsOneWidget); }); - testWidgets('[찾기]는 아직 준비 중이라 알리고, [메일 쓰기]는 쓰기 화면을 연다', ( + testWidgets('[찾기]는 검색 화면을 위에 쌓고(탭 막대는 그대로), [메일 쓰기]는 쓰기 화면을 연다', ( WidgetTester tester, ) async { h.mail.seed(kInboxId, 1); @@ -849,7 +853,11 @@ void main() { await tester.tap(find.byTooltip(MailListTexts.search)); await tester.pumpAndSettle(); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); await tester.tap(find.text(MailListTexts.compose)); await tester.pumpAndSettle(); @@ -1098,3 +1106,15 @@ void main() { expect(h.container.exists(mailStateWatcherProvider), isFalse); }); } + +/// 고를 수 없는 메일(내일 보낼 예약) — 사진을 누르면 단추 없는 알림이 뜬다 +const String _heldSubject = '예약한 메일'; + +Map _heldMessage(DateTime now) => messageJson( + 'held', + mailboxId: kInboxId, + subject: _heldSubject, + isRead: true, + receivedAt: now.subtract(const Duration(days: 1)), + pendingSend: pendingJson(now.add(const Duration(days: 1)), kind: 'scheduled'), +); diff --git a/flutter/test/features/mail/mail_repository_test.dart b/flutter/test/features/mail/mail_repository_test.dart index 7b74bc2..2d2c9cf 100644 --- a/flutter/test/features/mail/mail_repository_test.dart +++ b/flutter/test/features/mail/mail_repository_test.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; @@ -498,4 +500,147 @@ void main() { expect(apiErrorOf(error).code, 'FILE_007'); }); }); + + group('검색 (계약 2절 "검색 — 14단계")', () { + test( + '검색어 · 메일함 · 쪽 크기 · 커서를 그대로 보낸다 — 전체 메일함이면 mailboxId 를 뺀다, silent 면 알리지 않는다', + () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + { + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': null, + }, + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': null, + }, + }), + ); + + final DateTime serverTime = DateTime.utc(2026, 11, 7, 2); + h.server.serverTime = serverTime; + + final MailPage page = await repository.searchMessages( + q: '계약', + mailboxId: kInboxId, + cursor: 'abc', + silent: true, + ); + expect(page.items.single.highlightSupported, isTrue); + expect(page.pageInfo.total, isNull); + // 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + expect(page.serverTime, serverTime); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + 'cursor': 'abc', + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isTrue); + + await repository.searchMessages(q: '계약'); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'limit': kMailPageSize, + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isNot(isTrue)); + }, + ); + + test('다음 쪽의 실패는 알린다 · 끊은 요청은 알리지 않는다', () async { + final Completer gate = Completer(); + h.server.on('GET', MailApiPaths.messages, (_) async { + await gate.future; + return FakeResponse.fail(503, 'MAIL_001'); + }); + final CancelToken cancel = CancelToken(); + final Future cancelled = errorOf( + repository.searchMessages(q: '계약', cursor: 'c1', cancelToken: cancel), + ); + await pumpEventsUntil(() => h.server.requests.isNotEmpty); + cancel.cancel(); + final Object error = await cancelled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + expect( + apiErrorOf( + await errorOf(repository.searchMessages(q: '계약', cursor: 'c1')), + ).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test( + '준비 상태 — building · ready, 모르는 값은 null, 받지 못하면(404) 알리지 않고 던진다', + () async { + Object? status = 'building'; + h.server.on( + 'GET', + MailApiPaths.searchIndex, + (_) => status == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok({ + 'status': status, + 'done': 3, + 'total': 10, + }), + ); + expect( + await repository.searchIndexStatus(), + SearchIndexStatus.building, + ); + status = 'ready'; + expect(await repository.searchIndexStatus(), SearchIndexStatus.ready); + status = 'paused'; + expect(await repository.searchIndexStatus(), isNull); + status = null; + expect( + apiErrorOf(await errorOf(repository.searchIndexStatus())).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('메일 한 통 — 강조 쿼리는 빈 칸을 빼고 싣는다(외부 이미지와 함께도)', () async { + h.server.onPattern( + 'GET', + RegExp(r'^/messages/m1$'), + (_) => FakeResponse.ok({ + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }), + ); + final ({MessageDetail detail, DateTime? serverTime}) result = + await repository.getMessage( + 'm1', + showImages: true, + highlight: const MessageHighlightQuery(q: '계약'), + ); + expect(h.server.requests.last.queryParameters, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(result.detail.searchHighlight?.subject, <({int start, int end})>[ + (start: 0, end: 2), + ]); + + await repository.getMessage('m1'); + expect(h.server.requests.last.queryParameters, isEmpty); + }); + }); } diff --git a/flutter/test/features/mail/mail_search_controller_test.dart b/flutter/test/features/mail/mail_search_controller_test.dart new file mode 100644 index 0000000..3a27c7f --- /dev/null +++ b/flutter/test/features/mail/mail_search_controller_test.dart @@ -0,0 +1,729 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/provider_harness.dart'; + +class _Foreground extends AppForeground { + @override + bool build() => true; +} + +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 검색 응답 한 쪽 +FakeResponse _page( + List> items, { + String? next, + int? total, +}) => FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': next != null, + 'nextCursor': next, + 'total': total, + }, +}); + +/// 받은편지함의 메일 [n] 통 — id 는 `1` 부터(앞이 최신) +List> _mails( + int n, { + String prefix = 'r', + int from = 1, + String mailboxId = kInboxId, +}) => >[ + for (int i = from; i < from + n; i++) + messageJson( + '$prefix$i', + mailboxId: mailboxId, + subject: '계약 $prefix$i', + receivedAt: kTestNow.subtract(Duration(minutes: i)), + isRead: true, + ), +]; + +/// 메일 검색 컨트롤러 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태 · 결과 반영) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + int instance = 0; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue((String _) {}), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + /// 검색 화면 하나를 연다(화면이 보는 것처럼 구독한다) — 메일함 목록을 받을 때까지 기다린다 + Future open({String? origin}) async { + final MailSearchArgs args = MailSearchArgs( + instance: ++instance, + originMailboxId: origin, + ); + container + ..listen(mailboxesProvider, (MailboxesState? _, MailboxesState _) {}) + ..listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + reason: '메일함 목록', + ); + return args; + } + + MailSearchState stateOf(MailSearchArgs args) => + container.read(mailSearchProvider(args)); + + MailSearchController controllerOf(MailSearchArgs args) => + container.read(mailSearchProvider(args).notifier); + + /// 검색이 멈출 때까지(찾는 중 · 이어 찾는 중이 끝날 때까지) + Future settle(MailSearchArgs args) => pumpEventsUntil(() { + final MailSearchState s = stateOf(args); + return s.phase != MailSearchPhase.searching && + s.fill == MailSearchFill.none && + !s.refreshing; + }, reason: '검색'); + + List ids(MailSearchArgs args) => [ + for (final MailSearchItem item in stateOf(args).items) item.id, + ]; + + List searchRequests() => [ + for (final RequestOptions r in mail.listRequests) + if (r.queryParameters.containsKey('q')) r, + ]; + + MessageSummary summaryOf(Map json) => + MessageSummary.tryParse(json)!; + + group('찾기', () { + test('빈 칸 · 공백뿐이면 묻지 않고 거짓 — 찾기 전 그대로', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(''), isFalse); + expect(controllerOf(args).submit(' \u3000\n'), isFalse); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).phase, MailSearchPhase.idle); + }); + + test( + '받은편지함 주소에서 열면 기본 메일함에서 찾는다 — q · mailboxId · limit, 첫 쪽은 알리지 않는다', + () async { + mail.messages[kInboxId] = _mails(3); + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(' 계약 '), isTrue); + expect(stateOf(args).phase, MailSearchPhase.searching); + expect(stateOf(args).query, '계약'); + await settle(args); + + final RequestOptions request = searchRequests().single; + expect(request.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(request.extra[kSilentExtraKey], isTrue); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + expect(stateOf(args).searchedMailboxId, kInboxId); + }, + ); + + test('전체 메일함은 mailboxId 를 빼고 묻는다 — 시작한 메일함은 그 메일함', () async { + final MailSearchArgs args = await open(origin: kClientsId); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests().last.queryParameters['mailboxId'], kClientsId); + + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + expect(stateOf(args).searchedAll, isTrue); + }); + + test('낱말이 8개를 넘으면 묻지 않는다 — 찾을 말이 너무 많음', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), isTrue); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.tooManyWords); + expect(stateOf(args).query, isNull); + expect(searchRequests(), isEmpty); + }); + + test('첫 쪽을 받지 못하면 받지 못함(알리지 않는다 — 화면이 그린다) · [다시 시도]는 처음부터', () async { + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(stateOf(args).error?.code, 'MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchResponder = null; + mail.messages[kInboxId] = _mails(1); + await controllerOf(args).retry(); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1']); + }); + + test('찾던 메일함이 지워졌으면 RES_001 — [전체 메일함에서 찾기]는 보이는 검색어를 전체에서', () async { + final MailSearchArgs args = await open(origin: 'mb-gone'); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).error?.code, 'RES_001'); + + controllerOf(args).searchAll(); + await settle(args); + expect(stateOf(args).allMailboxes, isTrue); + expect(searchRequests().last.queryParameters['q'], '계약'); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + }); + + test('같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다(결과를 두고) — 실패하면 그대로 두고 알린다', () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + controllerOf(args).submit('계약'); + expect(stateOf(args).refreshing, isTrue); + expect(ids(args), ['r1', 'r2']); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2']); + expect(h.notifier.shownCodes, ['MAIL_001']); + expect(searchRequests().last.extra[kSilentExtraKey], isNot(isTrue)); + }); + + test('[✕]는 결과를 비우고 찾기 전으로 — 찾을 곳은 그대로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + controllerOf(args).submit('계약'); + await settle(args); + controllerOf(args).clear(); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + }); + + test('찾을 곳 바꾸기 — 칸의 글로, 칸이 비었으면 보이는 결과의 검색어로, 둘 다 없으면 곳만', () async { + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + + controllerOf(args).setScope(all: false, fieldText: '견적'); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + + controllerOf(args).setScope(all: true, fieldText: ' '); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + expect(stateOf(args).searchedAll, isTrue); + }); + }); + + group('이어 찾기 (한 차례 5번 · 한 쪽 20통)', () { + test( + '하나도 못 찾고 hasNext 면 커서로 5번까지 — 그래도 없으면 [더 찾기](아직 못 찾음), 누르면 5번 더', + () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls <= 7) { + return _page(const >[], next: 'c$calls'); + } + return _page(_mails(2), total: null); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('정산서'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(searchRequests()[1].queryParameters['cursor'], 'c1'); + expect( + [ + for (final RequestOptions r in searchRequests().skip(1)) + r.extra[kSilentExtraKey], + ], + everyElement(isTrue), + reason: '하나도 찾기 전의 이어 찾기는 알리지 않는다', + ); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).partial, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(searchRequests(), hasLength(8)); + expect(ids(args), ['r1', 'r2']); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test( + '한 쪽이 차면 멈추고, 끝에 닿으면 다음 차례 — 다음 쪽의 total 이 null 이면 첫 쪽 수를 남긴다', + () async { + mail.searchResponder = (Map query) { + final Object? cursor = query['cursor']; + if (cursor == null) return _page(_mails(12), next: 'c1', total: 30); + if (cursor == 'c1') { + return _page(_mails(10, from: 13), next: 'c2'); + } + return _page(_mails(8, from: 23)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).items, hasLength(22)); + expect(stateOf(args).total, 30); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isTrue); + expect(searchRequests()[1].extra[kSilentExtraKey], isNot(isTrue)); + + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(3)); + expect(stateOf(args).items, hasLength(30)); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).total, 30); + }, + ); + + test('한 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않는다 — 목록 끝의 [더 찾기]', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(3) : const >[], + next: 'c$calls', + ); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('견적'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(stateOf(args).items, hasLength(3)); + expect(stateOf(args).partial, isTrue); + + // 끝 표시가 불러도 잇지 않는다 — [더 찾기]로만 + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(3), next: 'same', total: 10) + : _page(const >[], next: 'same'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(stateOf(args).partial, isFalse); + }); + + test('이어 찾다 실패하면 받은 데까지 보이고 끝에 [다시 시도] — 인터셉터가 알린다', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls == 1) return _page(_mails(3), next: 'c1', total: 9); + if (calls == 2) return FakeResponse.fail(503, 'MAIL_001'); + return _page(_mails(6, from: 4)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(3)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(h.notifier.shownCodes, ['MAIL_001']); + + await controllerOf(args).loadMore(); + expect(ids(args), hasLength(9)); + expect(stateOf(args).loadMoreFailed, isFalse); + expect(stateOf(args).hasMore, isFalse); + }); + + test('하나도 찾기 전의 이어 찾기가 실패하면 받지 못함(알리지 않는다)', () async { + int calls = 0; + mail.searchResponder = (_) { + calls++; + return calls == 1 + ? _page(const >[], next: 'c1') + : FakeResponse.fail(503, 'MAIL_001'); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('늦은 응답 · 세션', () { + test('새 검색을 하면 앞 검색의 요청을 끊고 그 응답은 버린다 — 끊은 요청은 알리지 않는다', () async { + mail.messages[kInboxId] = >[ + ..._mails(2), + ..._mails(1, prefix: 'q'), + ]; + final MailSearchArgs args = await open(); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('r1'); + await pumpEventsUntil(() => searchRequests().length == 1); + controllerOf(args).submit('q1'); + await settle(args); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + + slow.complete(); + await pumpEventsUntil(() => true); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('화면이 닫히면(구독이 끊기면) 도는 요청을 끊는다 — 늦은 응답이 와도 아무 일 없다', () async { + final MailSearchArgs args = MailSearchArgs(instance: ++instance); + final ProviderSubscription subscription = container + .listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + container.listen( + mailboxesProvider, + (MailboxesState? _, MailboxesState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + ); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('계약'); + await pumpEventsUntil(() => searchRequests().length == 1); + subscription.close(); + await pumpEventsUntil(() => !container.exists(mailSearchProvider(args))); + slow.complete(); + await pumpEventsUntil(() => true); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('세션이 바뀌면 처음(찾기 전)으로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + await pumpEventsUntil(() => stateOf(args).phase == MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + }); + }); + + group('준비 상태 (GET /search-index)', () { + test( + '찾을 때마다 조용히 묻는다 — building · ready 를 담고, 받지 못하면(404 · 실패) 모름', + () async { + mail.messages[kInboxId] = _mails(1); + mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 3, + 'total': 10, + }); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + await pumpEventsUntil( + () => stateOf(args).indexStatus == SearchIndexStatus.building, + ); + expect(mail.searchIndexRequests, 1); + expect( + h.server + .received('GET', MailApiPaths.searchIndex) + .single + .extra[kSilentExtraKey], + isTrue, + ); + + mail.searchIndex = FakeResponse.fail(404, 'RES_001'); + controllerOf(args).submit('견적'); + await settle(args); + await pumpEventsUntil(() => stateOf(args).indexStatus == null); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchIndex = FakeResponse.ok({ + 'status': 'paused', + }); + controllerOf(args).submit('보고'); + await settle(args); + await pumpEventsUntil(() => mail.searchIndexRequests == 3); + await pumpEventsUntil(() => true); + expect(stateOf(args).indexStatus, isNull); + }, + ); + }); + + group('읽기 화면이 바꾼 것 반영 (새 메일 확인으로는 다시 찾지 않는다)', () { + late MailSearchArgs args; + + Future searchInbox() async { + mail.messages[kInboxId] = _mails(3); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + } + + MailStateWatcher watcher() => + container.read(mailStateWatcherProvider.notifier); + + test('읽음 · 중요는 표시만 덮는다 — 제목 · 미리 보기는 그 응답의 것 그대로', () async { + await searchInbox(); + final int before = searchRequests().length; + watcher().markChanged( + updated: [ + summaryOf({ + ...messageJson( + 'r2', + mailboxId: kInboxId, + subject: '바뀐 제목(NFC 아님)', + isFlagged: true, + ), + }), + ], + ); + final MailSearchItem item = stateOf(args).items[1]; + expect(item.summary.isFlagged, isTrue); + expect(item.summary.isRead, isFalse); + expect(item.summary.subject, '계약 r2'); + expect(searchRequests(), hasLength(before)); + }); + + test( + '한 메일함에서 찾았으면 그 메일함을 떠난 메일을 빼고 · [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에', + () async { + await searchInbox(); + final Map moved = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), ['r1', 'r3']); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + }, + ); + + test( + '전체 메일함에서 찾았으면 휴지통 · 스팸메일함만 남은 메일을 빼고, 다른 메일함으로 옮긴 메일은 메일함만 바꾼다', + () async { + mail.messages[kInboxId] = _mails(2); + args = await open(); + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + + final Map toClients = Map.of( + mail.messages[kInboxId]![0], + )..['mailboxIds'] = [kClientsId]; + final Map toJunk = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kJunkId]; + watcher().markChanged( + updated: [summaryOf(toClients), summaryOf(toJunk)], + ); + expect(ids(args), ['r1']); + expect(stateOf(args).items.single.summary.mailboxIds, [ + kClientsId, + ]); + }, + ); + + test('영구 삭제 · 발송 취소(removedIds)는 빼고 기억하지 않는다 · 바뀐 것 없는 소식은 무시한다', () async { + await searchInbox(); + watcher().markChanged(removedIds: {'r1'}); + expect(ids(args), ['r2', 'r3']); + expect(stateOf(args).total, 2); + + watcher().markChanged(); + expect(ids(args), ['r2', 'r3']); + expect(searchRequests(), hasLength(1)); + }); + + test( + '불러온 끝 줄을 뺐다 [되돌리기]해도 그 자리(끝)에 다시 넣고 수도 되돌린다 — 다음 쪽은 그 줄 다음부터라 빠지지도 겹치지도 않는다', + () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(kMailPageSize)); + expect(stateOf(args).hasMore, isTrue); + expect(stateOf(args).total, 21); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kTrashId))], + ); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kInboxId))], + ); + expect(ids(args), hasLength(kMailPageSize)); + expect(ids(args).last, 'r20'); + expect(stateOf(args).total, 21); + + await controllerOf(args).loadMore(); + expect(ids(args), [for (int i = 1; i <= 21; i++) 'r$i']); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test('1통만 찾고 [더 찾기]로 멈춘 결과에서 그 1통을 뺐다 되돌리면 다시 보인다 — [더 찾기]는 그대로', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(1) : const >[], + next: 'c$calls', + total: calls == 1 ? 3 : null, + ); + }; + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + + final Map moved = Map.of( + _mails(1).single, + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + expect(stateOf(args).total, 3); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('불러온 결과를 모두 뺐는데 서버에 더 있으면 [더 찾기]로 잇는다 — 목록 끝이 없다', () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).partial, isFalse); + + watcher().markChanged( + removedIds: {for (int i = 1; i <= kMailPageSize; i++) 'r$i'}, + ); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(ids(args), ['r21']); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).waitsForMore, isFalse); + }); + + test('이 검색에서 뺀 메일이라도 서버가 다음 쪽에 주면 넣는다 — 겹침은 지금 목록의 id 로만 막는다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(20), next: 'c1', total: 21) + // 다른 곳에서 되돌린 r5 · 새 r21 — 이미 있는 r1 은 한 번만 + : _page(>[ + ..._mails(1), + ..._mails(1, from: 5), + ..._mails(1, from: 21), + ]); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + final Map moved = Map.of(_mails(5).last) + ..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + await controllerOf(args).loadMore(); + expect(ids(args).sublist(19), ['r5', 'r21']); + expect(ids(args).where((String id) => id == 'r1'), hasLength(1)); + expect(stateOf(args).total, 21); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart new file mode 100644 index 0000000..d180816 --- /dev/null +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -0,0 +1,1125 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 보드 `App1Search` 3번의 메일 — ⟦계약⟧ 이 맞은 말 +const String _doyunSubject = 'Re: 유지보수 계약 갱신 건'; +const String _doyunPreview = '말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.'; +const String _hrSubject = '[공지] 계약직 채용 일정 안내'; +const String _jihoonSubject = '견적서 보내 드립니다'; + +/// 메일 검색 화면 (앱 보드 `App1Search` · `App1SearchStates`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + /// [finder] 안의 글 조각 가운데 노랑 바탕(검색 강조)인 것 + List marksIn(WidgetTester tester, Finder finder) { + final List marks = []; + for (final Text text in tester.widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + )) { + text.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.style?.backgroundColor == AppColors.searchMark) { + marks.add(span.text ?? ''); + } + return true; + }); + } + return marks; + } + + /// 결과 목록의 스크롤 — 칸(두 줄 TextField)에도 Scrollable 이 있다 + Finder results() => find + .descendant(of: find.byType(ListView), matching: find.byType(Scrollable)) + .first; + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + /// 목록([location] — 없으면 받은편지함)을 띄우고 [찾기]로 검색 화면을 연다 + Future openSearch(WidgetTester tester, {String? location}) async { + await h.pump(tester, location: location); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + /// 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + /// 보드 3번의 받은편지함 — 14단계 서버(강조 칸이 있다) + void seedContracts() { + final DateTime now = h.clock.now; + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'm1', + mailboxId: kInboxId, + subject: _doyunSubject, + preview: _doyunPreview, + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: _hrSubject, + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm3', + mailboxId: kInboxId, + fromName: '박지훈', + subject: _jihoonSubject, + preview: '안녕하세요. 지난달 계약 기간에 맞춰 단가를 다시 계산했습니다.', + receivedAt: now.subtract(const Duration(days: 1)), + isRead: true, + ), + ); + h.mail.searchHighlights + ..['m1'] = { + 'subject': [ + [9, 11], + ], + 'preview': { + 'text': _doyunPreview, + 'ranges': [ + [10, 12], + ], + }, + } + ..['m2'] = { + 'subject': [ + [5, 7], + ], + 'preview': {'text': '채용 일정입니다', 'ranges': []}, + } + ..['m3'] = { + 'subject': [], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }; + } + + group('들어가고 나가기', () { + testWidgets('목록의 [찾기]는 탭 안에 검색 화면을 쌓는다 — 칸에 초점 · 찾기 전 안내 · 찾을 곳은 시작한 메일함', ( + WidgetTester tester, + ) async { + await openSearch(tester); + + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(find.byTooltip(MailSearchTexts.clear), findsNothing); + expect( + tester.getSemantics(find.widgetWithText(InkWell, '받은편지함').first), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.text(MailSearchTexts.scopeAll), findsOneWidget); + expect(findCopy(MailSearchTexts.scopeAllHint), findsNothing); + expect(h.mail.searchQueries, isEmpty); + }); + + testWidgets( + '[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.enterText(field(), '계약'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + expect(find.bySemanticsLabel(MailSearchTexts.loading), findsWidgets); + expect(fieldFocused(tester), isFalse); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(marksIn(tester, row(_hrSubject)), ['계약']); + // 본문에서만 맞은 메일 — 미리 보기를 서버의 맞은 말 둘레 한 줄로 + expect(find.text('…지난달 계약 기간에 맞춰'), findsOneWidget); + expect(marksIn(tester, row(_jihoonSubject)), ['계약']); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + }, + ); + + testWidgets('미리 보기 줄의 강조 안 글자는 진하게(#4A5363), 제목 강조는 글자색 그대로', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + + Color? markColorIn(String text) { + Color? found; + for (final Text widget in tester.widgetList(find.byType(Text))) { + widget.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.text == '계약' && + span.style?.backgroundColor == AppColors.searchMark && + widget.textSpan!.toPlainText() == text) { + found = span.style?.color; + } + return true; + }); + } + return found; + } + + expect(markColorIn(_doyunPreview), AppColors.textSecondary); + expect(markColorIn(_doyunSubject), isNull); + }); + + testWidgets('빈 칸 · 공백뿐인 [검색]은 아무 일도 없다 — 자판도 그대로', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, ' \u3000 '); + expect(h.mail.searchQueries, isEmpty); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('붙여 넣은 줄바꿈은 띄어쓰기로 바꾸고(낱말이 붙지 않게) 200자(UTF-16)까지 받는다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '회의\r\n일정\n공유'); + await tester.pump(); + expect(tester.widget(field()).controller!.text, '회의 일정 공유'); + + await tester.enterText(field(), '가' * 250); + await tester.pump(); + expect( + tester.widget(field()).controller!.text.length, + kMailSearchMaxLength, + ); + }); + + testWidgets('낱말이 9개면 묻지 않고 "찾을 말이 너무 많습니다"', (WidgetTester tester) async { + await openSearch(tester); + await search(tester, '11월 정기 점검 일정 안내 메일 다시 보내 주세요'); + + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.searching('11월')), findsNothing); + }); + + testWidgets('[✕]는 칸과 결과를 비우고 칸에 초점 · [←]는 검색을 닫는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text(_doyunSubject), findsOneWidget); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(tester.widget(field()).controller!.text, isEmpty); + expect(find.text(_doyunSubject), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + + testWidgets('뒤로 가기 · 메일 탭 다시 누르기도 검색을 닫는다', (WidgetTester tester) async { + await openSearch(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(findAppModule(AppModuleId.mail).label), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + }); + + testWidgets('다른 메일함 목록에서 열면 그 메일함에서 찾는다', (WidgetTester tester) async { + h.mail.put( + kClientsId, + messageJson('c1', mailboxId: kClientsId, subject: '거래처 계약'), + ); + await openSearch(tester, location: MailRoutes.mailbox(kClientsId)); + expect( + find.descendant( + of: find.byType(MailSearchScopeBar), + matching: find.text('거래처'), + ), + findsOneWidget, + ); + await search(tester, '계약'); + expect(h.mail.searchQueries.single['mailboxId'], kClientsId); + expect(find.text('거래처 계약'), findsOneWidget); + }); + }); + + group('찾을 곳 · 다시 찾기', () { + testWidgets('전체 메일함으로 바꾸면 칸의 글로 다시 찾는다 — 휴지통 · 스팸 안내, 줄마다 메일함 · 받는 사람', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '유지보수 계약 문의', + to: >[addressJson('이도윤', 'doyun@example.com')], + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(days: 9)), + ), + ); + h.mail.put( + kTrashId, + messageJson('t1', mailboxId: kTrashId, subject: '휴지통 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text('유지보수 계약 문의'), findsNothing); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(find.text('${MailListTexts.recipientPrefix}이도윤'), findsOneWidget); + expect( + find.descendant( + of: row('유지보수 계약 문의'), + matching: find.text(MailSearchTexts.rowMailbox('보낸편지함')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: row(_doyunSubject), + matching: find.text(MailSearchTexts.rowMailbox('받은편지함')), + ), + findsOneWidget, + ); + expect(find.text('휴지통 계약'), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 4)), findsOneWidget); + }); + + testWidgets('칸이 비었으면 찾을 곳만 바꿔 둔다(자판도 그대로) — 다음 검색이 그 곳에서', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await search(tester, '계약'); + expect(h.mail.searchQueries.single.containsKey('mailboxId'), isFalse); + }); + + testWidgets('칸을 지운 채 찾을 곳을 바꾸면 보이는 결과의 검색어로 다시 찾고 자판을 내린다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + await tester.enterText(field(), ''); + await tester.pumpAndSettle(); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last['q'], '계약'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(fieldFocused(tester), isFalse); + }); + + testWidgets('같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 보인 채로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.showKeyboard(field()); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(find.text(_doyunSubject), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + gate.complete(); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + + await tester.fling(find.text(_doyunSubject), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + expect(find.text(_doyunSubject), findsOneWidget); + }); + + testWidgets('새 메일 확인으로는 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 계약'), + ); + await elapse(tester, kMailStatePollInterval + const Duration(seconds: 1)); + expect(h.mail.searchQueries, hasLength(1)); + expect(find.text('새 계약'), findsNothing); + }); + }); + + group('상태 화면', () { + testWidgets('결과 없음 — 한 메일함이면 [전체 메일함에서 찾기], 전체 메일함 결과 없음에는 단추가 없다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, '해지 통보'); + + expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['q'], '해지 통보'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(find.text(MailSearchTexts.searchAll), findsNothing); + }); + + testWidgets('검색 준비 중이면 안내 줄 · 결과 없음에 옛 한계 한 줄 — 준비 상태를 받지 못하면 아무 줄도 없다', ( + WidgetTester tester, + ) async { + h.mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 1, + 'total': 9, + }); + await openSearch(tester); + await search(tester, '서버'); + expect(findCopy(MailSearchTexts.indexing), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsOneWidget); + + h.mail.searchIndex = FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '이전'); + expect(findCopy(MailSearchTexts.indexing), findsNothing); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('한 글자로만 찾으면(색인이 준비됐을 때) 본문은 찾지 않았다고 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson( + 'k1', + mailboxId: kInboxId, + fromName: '김민수', + subject: '김 회의', + ), + ); + await openSearch(tester); + await search(tester, '김'); + expect(findCopy(MailSearchTexts.singleChar), findsOneWidget); + + await search(tester, '김 회의'); + expect(findCopy(MailSearchTexts.singleChar), findsNothing); + }); + + testWidgets( + '받지 못함 — 사전 문구 · 문의 코드 · [다시 시도], 찾던 메일함이 지워졌으면 [전체 메일함에서 찾기]', + (WidgetTester tester) async { + h.mail.searchResponder = (_) => FakeResponse.fail(500, 'SYS_001'); + await openSearch(tester); + await search(tester, '계약'); + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-SY')), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.searchResponder = (_) => FakeResponse.fail(404, 'RES_001'); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.searchAll), findsOneWidget); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.emptyTitle('계약')), findsOneWidget); + }, + ); + + testWidgets('아직 못 찾음 — [더 찾기]를 누른 동안 "찾는 중…"(다시 누르지 못한다) → 결과', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + if (calls <= kMailSearchFillMaxRequests) { + return FakeResponse.ok({ + 'items': [], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + } + return FakeResponse.ok({ + 'items': [ + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ], + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '정산서'); + + expect(find.text(MailSearchTexts.quoted('정산서')), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + expect(findCopy(MailSearchTexts.partialHint), findsOneWidget); + + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pump(); + expect(find.text(MailSearchTexts.moreBusy), findsOneWidget); + expect( + tester.getSemantics(find.text(MailSearchTexts.moreBusy)), + isSemantics(isButton: true, isEnabled: false), + ); + await tester.tap(find.text(MailSearchTexts.moreBusy)); + await tester.pump(); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsOneWidget); + // 찾는 동안 다시 누른 [찾는 중…]은 요청을 더 보내지 않았다 + expect(h.mail.searchQueries, hasLength(kMailSearchFillMaxRequests + 1)); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + }); + + testWidgets('목록 끝 — 한 차례가 한 쪽을 못 채우면 안내 줄 + [더 찾기], 누르면 이어 찾는다', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (_) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) + messageJson('q1', mailboxId: kInboxId, subject: '견적 요청'), + if (calls == 6) + messageJson( + 'q2', + mailboxId: kInboxId, + subject: '견적 다시', + receivedAt: h.clock.now.subtract(const Duration(days: 30)), + ), + ], + 'pageInfo': { + 'hasNext': calls < 6, + 'nextCursor': calls < 6 ? 'c$calls' : null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '견적'); + expect(find.text('견적 요청'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(calls, kMailSearchFillMaxRequests); + + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pumpAndSettle(); + expect(find.text('견적 다시'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsNothing); + }); + + testWidgets('한 쪽을 채웠으면 끝 표시가 보일 때 다음 차례를 저절로 · 다음 쪽 실패는 끝의 [다시 시도]', ( + WidgetTester tester, + ) async { + final List> many = >[ + for (int i = 1; i <= 25; i++) + messageJson( + 'n$i', + mailboxId: kInboxId, + subject: '계약 $i', + receivedAt: h.clock.now.subtract(Duration(minutes: i)), + isRead: true, + ), + ]; + h.mail.messages[kInboxId] = many; + await openSearch(tester); + // 첫 쪽(20통 · 전체 25)은 기본 검색 그대로, 다음 쪽은 실패 + h.mail.searchResponder = (Map query) => + query['cursor'] == null + ? h.mail.defaultSearch(query) + : FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '계약'); + expect(h.mail.searchQueries, hasLength(1)); + + await tester.scrollUntilVisible( + find.text(MailListTexts.retry), + 500, + scrollable: results(), + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.notifier.shownCodes, ['MAIL_001']); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + // [다시 시도]는 받지 못한 그 커서로 다시 묻는다 + expect( + h.mail.searchQueries.last['cursor'], + FakeMailBackend.searchCursorAfter(many[19]), + ); + expect( + h.mail.searchQueries.last['cursor'], + h.mail.searchQueries[1]['cursor'], + ); + await tester.scrollUntilVisible( + find.text('계약 25'), + 500, + scrollable: results(), + ); + expect(find.text(MailSearchTexts.count('계약', 25)), findsOneWidget); + }); + }); + + group('결과에서 열기 · 결과 반영', () { + testWidgets('결과를 누르면 읽기 화면 — 강조 쿼리 · 메일함 줄, 제목 · 첨부 이름 · 글 본문을 칠한다', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.highlightDetails['m1'] = { + 'textBody': '도윤입니다.\n말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.', + 'attachments': [attachmentJson('a1', name: '계약서_초안_v2.pdf')], + 'searchHighlight': { + 'subject': [ + [9, 11], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [ + [17, 19], + ], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, {'q': '계약'}); + final Finder header = find.byType(ReaderHeader); + expect(marksIn(tester, header), ['계약']); + expect( + find.descendant( + of: header, + matching: find.text(MailSearchTexts.mailboxLine), + ), + findsOneWidget, + ); + expect( + find.descendant(of: header, matching: find.text('받은편지함')), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(MessageReaderPage)), [ + '계약', + '계약', + ]); + final String document = h.webView.last.lastHtml!; + expect(document, contains('조건으로 계약서')); + expect(document, contains('mark{background:#FCE7A4;color:inherit')); + }); + + testWidgets('14단계 앞 서버(강조 칸 없음)의 결과는 강조 쿼리 없이 연다 — 메일함 줄은 있다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('o1', mailboxId: kInboxId, subject: '옛 서버의 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('옛 서버의 계약')); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(find.text(MailSearchTexts.mailboxLine), findsOneWidget); + }); + + testWidgets('[이미지 보기]는 같은 강조 쿼리로 다시 받는다', (WidgetTester tester) async { + seedContracts(); + h.mail + ..details['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': true, + } + ..shownImageDetails['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': false, + 'imagesShown': true, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailReaderTexts.showImages)); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(h.webView.last.lastHtml, contains('계약 뉴스레터')); + }); + + testWidgets('붙잡힌 메일을 다시 봐 요약을 바꿔도 제목 강조는 강조를 받은 응답의 글로 그린다', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + subject: '계약 안내', + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 5)), + ), + ), + ); + h.mail.highlightDetails['h1'] = { + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('계약 안내')); + await tester.pumpAndSettle(); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + + // 보낼 시각 + 2초 — 쿼리 없이 다시 받는다(그사이 제목 글이 바뀌었다) + h.mail.messageOf('h1')!['subject'] = '계약 안내 (고침)'; + await elapse(tester, const Duration(seconds: 8)); + expect(h.mail.detailQueries.last.query, isEmpty); + expect( + find.descendant( + of: find.byType(ReaderHeader), + matching: find.text('계약 안내'), + ), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + }); + + testWidgets('임시보관함의 결과는 쓰기 화면에서 이어 쓴다', (WidgetTester tester) async { + h.mail.put( + kDraftsId, + messageJson('d1', mailboxId: kDraftsId, subject: '계약 초안 쓰는 중'), + ); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + await tester.tap(find.text('계약 초안 쓰는 중')); + await tester.pumpAndSettle(); + expect( + GoRouterState.of( + tester.element(find.byType(MessageComposePage).last), + ).uri.toString(), + MailRoutes.composeDraft('d1'), + ); + }); + + testWidgets('읽기 화면에서 읽으면 굵기가 풀리고, 휴지통으로 옮기면 빠진다 — [되돌리기]는 그 자리에 다시', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + Text whoOf(String subject) => tester.widget( + find.descendant(of: row(subject), matching: find.text('이도윤')), + ); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.bold); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.back)); + await tester.pumpAndSettle(); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.medium); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.text(_doyunSubject), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + expect(toastText(MailBulkTexts.moved(1, '휴지통')), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('m1'), kInboxId); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + final double doyun = tester.getTopLeft(row(_doyunSubject)).dy; + final double hr = tester.getTopLeft(row(_hrSubject)).dy; + expect(doyun, lessThan(hr)); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets('1통만 찾고 멈춘 결과를 지웠다 [되돌리기]하면 다시 보인다 — 아직 못 찾음 → 결과 + [더 찾기]', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ); + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) Map.of(h.mail.messageOf('p1')!), + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '정산서'); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + + await tester.tap(find.text('정산서 보냅니다')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsNothing); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('p1'), kInboxId); + expect(find.text('정산서 보냅니다'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + expect(calls, kMailSearchFillMaxRequests); + }); + + testWidgets('결과의 붙잡힌 메일은 보낼 시각 + 2초에 그 줄만 다시 받는다 — 표시만 덮고 제목 · 강조는 그대로', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ); + h.mail.searchHighlights['s1'] = { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '미리 보기 글', 'ranges': []}, + }; + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + Finder sending() => find.descendant( + of: row('계약 보내는 중'), + matching: find.text(MailListTexts.sending), + ); + expect(sending(), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.messageOf('s1')! + ..['pendingSend'] = null + ..['subject'] = '계약 보내는 중 (다른 글)'; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.detailQueries, hasLength(before)); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(h.mail.detailQueries.last.id, 's1'); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(sending(), findsNothing); + expect(marksIn(tester, row('계약 보내는 중')), ['계약']); + expect(h.mail.searchQueries, hasLength(1)); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('붙잡힌 줄을 다시 받다 실패하면 5초 뒤 다시(알리지 않는다) · 메일이 없어졌으면(RES_001) 뺀다', ( + WidgetTester tester, + ) async { + h.mail + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ) + ..put( + kSentId, + messageJson( + 's2', + mailboxId: kSentId, + subject: '계약 보낸 메일', + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(hours: 1)), + ), + ); + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.detailFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.detailFailure = FakeResponse.fail(404, 'RES_001'); + await elapse(tester, kHeldReleaseRecheck); + expect(h.mail.detailQueries, hasLength(before + 2)); + expect(find.text('계약 보내는 중'), findsNothing); + expect(find.text('계약 보낸 메일'), findsOneWidget); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('낭독', () { + testWidgets( + '개수 줄은 결과가 오면 읽고(live region) · 결과 줄은 메일함 이름까지 읽는다 · 사진은 단추가 아니다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + + expect( + tester.getSemantics(find.text(MailSearchTexts.count('계약', 3))), + isSemantics(isLiveRegion: true), + ); + expect( + tester.getSemantics(row(_hrSubject)).label, + contains('인사팀, 받은편지함, '), + ); + expect(find.byType(MailAvatarButton), findsNothing); + + // 길게 눌러도 아무 일 없다 — 고르지 않고 열지도 않는다 + await tester.longPress(find.text(_hrSubject)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.byType(MailSearchScopeBar), findsOneWidget); + }, + ); + + testWidgets('찾을 곳은 "찾을 곳" 묶음 안의 두 단추 — 고른 칸이 고름 상태', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: false), + ); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.bySemanticsLabel(MailSearchTexts.scopeLabel), findsWidgets); + }); + + testWidgets('검색 칸의 이름은 한 번만 읽는다 — 빈 칸 · 글을 쓴 뒤 모두(보이는 안내 글은 낭독에서 뺀다)', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect(find.text(MailSearchTexts.box), findsOneWidget); + expect(tester.getSemantics(field()).label, MailSearchTexts.box); + + await tester.enterText(field(), '계약'); + await tester.pump(); + final SemanticsNode node = tester.getSemantics(field()); + expect(node.label, MailSearchTexts.box); + expect(node.value, '계약'); + }); + }); + + group('크기', () { + Future countLineHeight(WidgetTester tester, double scale) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchCountLine(MailSearchTexts.count('계약', 4)), + ], + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchCountLine)).height; + } + + testWidgets('개수 줄은 높이 36 — 큰 글자에서는 글에 맞춰 늘어난다(잘리지 않는다)', ( + WidgetTester tester, + ) async { + expect(await countLineHeight(tester, 1), AppSize.searchCountLineHeight); + final double large = await countLineHeight(tester, 3); + expect(large, greaterThan(AppSize.searchCountLineHeight)); + expect( + large, + greaterThanOrEqualTo( + tester.getSize(find.text(MailSearchTexts.count('계약', 4))).height, + ), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_view_test.dart b/flutter/test/features/mail/mail_search_view_test.dart new file mode 100644 index 0000000..5a1c069 --- /dev/null +++ b/flutter/test/features/mail/mail_search_view_test.dart @@ -0,0 +1,306 @@ +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +final MailboxesState _mailboxes = MailboxesState( + status: MailboxesStatus.ready, + items: [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ], +); + +MailSearchItem _item( + String id, { + required String mailboxId, + String subject = '유지보수 계약 갱신 건', + String preview = '본문 첫 부분', + List> to = const >[], + bool isRead = true, + bool hasAttachment = false, + Map? highlight, + bool supported = true, +}) => MailSearchItem.tryParse({ + ...messageJson( + id, + mailboxId: mailboxId, + subject: subject, + preview: preview, + to: to, + isRead: isRead, + hasAttachment: hasAttachment, + ), + if (supported) 'searchHighlight': highlight, +})!; + +MailSearchState _ready({ + String query = '계약', + bool all = false, + String? mailboxId = kInboxId, + int? total = 4, + SearchIndexStatus? index, + bool refreshing = false, +}) => MailSearchState( + phase: MailSearchPhase.ready, + allMailboxes: all, + query: query, + searchedAll: all, + searchedMailboxId: all ? null : mailboxId, + pageInfo: MailPageInfo(hasNext: false, total: total), + indexStatus: index, + refreshing: refreshing, +); + +/// 검색 화면의 표시 — 결과 머리 · 안내 줄 · 결과 줄 · 연 메일에 넘기는 값 (보드 `App1Search`) +void main() { + group('결과 머리(개수 줄)', () { + test('찾은 뒤 "‘계약’ · 4통", 전체 수를 모르면 검색어만, 찾는 동안 · 다시 찾는 동안은 "찾는 중"', () { + expect(searchCountLineOf(_ready()), MailSearchTexts.count('계약', 4)); + expect( + searchCountLineOf(_ready(total: null)), + MailSearchTexts.quoted('계약'), + ); + expect( + searchCountLineOf(_ready(refreshing: true)), + MailSearchTexts.searching('계약'), + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.searching, query: '계약'), + ), + MailSearchTexts.searching('계약'), + ); + }); + + test('찾기 전 · 낱말이 너무 많음 · 받지 못함에는 머리가 없다', () { + expect(searchCountLineOf(const MailSearchState()), isNull); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.tooManyWords), + ), + isNull, + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.failed, query: '계약'), + ), + isNull, + ); + }); + }); + + group('안내 줄', () { + test('준비 중이면 준비 중 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 함께 뜨지 않는다', () { + expect( + searchNoticeOf(_ready(index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.ready)), + MailSearchNotice.singleChar, + ); + expect(searchNoticeOf(_ready(index: SearchIndexStatus.ready)), isNull); + }); + + test('준비 상태를 모르면(받지 못함 · 14단계 앞 서버) 아무 줄도 없다 · 찾는 동안에도 없다', () { + expect(searchNoticeOf(_ready(query: '김')), isNull); + expect( + searchNoticeOf( + const MailSearchState( + phase: MailSearchPhase.searching, + query: '김', + indexStatus: SearchIndexStatus.ready, + ), + ), + isNull, + ); + }); + }); + + group('찾을 곳의 시작한 메일함 이름', () { + test('받은편지함 주소는 기본 메일함 이름(받기 전이면 "받은편지함"), 그 밖은 그 메일함(모르면 "메일함")', () { + expect(searchOriginNameOf(null, _mailboxes), '받은편지함'); + expect( + searchOriginNameOf(null, const MailboxesState()), + MailListTexts.inbox, + ); + expect(searchOriginNameOf(kClientsId, _mailboxes), '거래처'); + expect( + searchOriginNameOf('mb-gone', _mailboxes), + MailListTexts.mailboxFallback, + ); + }); + }); + + group('결과 줄', () { + final DateTime now = kTestNow; + + test('한 메일함에서 찾으면 목록 줄 그대로 — 메일함 이름이 없다', () { + final MailRowView view = searchRowViewOf( + _item('m1', mailboxId: kInboxId), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.mailboxName, isNull); + expect(view.who, '이도윤'); + }); + + test('전체 메일함이면 줄마다 그 메일의 메일함 — 보낸편지함 · 임시보관함은 "받는 사람: …"', () { + final MailSearchState all = _ready(all: true); + final MailRowView sent = searchRowViewOf( + _item( + 's1', + mailboxId: kSentId, + to: >[addressJson('이도윤', 'doyun@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(sent.mailboxName, '보낸편지함'); + expect(sent.who, '${MailListTexts.recipientPrefix}이도윤'); + final MailRowView draft = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(draft.mailboxName, '임시보관함'); + expect(draft.who, '${MailListTexts.recipientPrefix}박지훈'); + // 한 메일함(임시보관함)에서 찾으면 이름만 + final MailRowView draftOnly = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: _ready(mailboxId: kDraftsId), + mailboxes: _mailboxes, + now: now, + ); + expect(draftOnly.who, '박지훈'); + }); + + test('강조가 있으면 미리 보기를 서버의 맞은 말 둘레 한 줄로 바꾸고 제목 · 미리 보기의 맞은 곳을 싣는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + preview: '엔진 미리 보기', + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.preview, '…지난달 계약 기간에'); + expect(view.subjectHighlights, [(start: 5, end: 7)]); + expect(view.previewHighlights, [(start: 5, end: 7)]); + // 강조가 없으면 지금 미리 보기 + final MailRowView plain = searchRowViewOf( + _item('m2', mailboxId: kInboxId, preview: '엔진 미리 보기'), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(plain.preview, '엔진 미리 보기'); + expect(plain.subjectHighlights, isEmpty); + }); + + test('제목이 없으면 "(제목 없음)"에 강조를 싣지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '', + highlight: { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '계약', 'ranges': []}, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.subject, MailListTexts.noSubject); + expect(view.subjectHighlights, isEmpty); + }); + + test('낭독 글은 안 읽음 · 보낸 사람 · 메일함 · 시각 · 제목 · 첨부 · 미리 보기 차례 — 강조는 읽지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '유지보수 계약', + preview: '계약서 초안', + isRead: false, + hasAttachment: true, + ), + state: _ready(all: true), + mailboxes: _mailboxes, + now: now, + ); + expect( + view.semanticsLabel, + [ + MailListTexts.unreadPrefix, + '이도윤', + '받은편지함', + formatMailListTime(kTestNow, now), + '유지보수 계약', + MailListTexts.attachment, + '계약서 초안', + ].join(', '), + ); + }); + }); + + group('결과에서 연 메일에 넘기는 값', () { + test('14단계 서버의 결과만 강조 쿼리를 싣는다 — 한 메일함이면 그 메일함도', () { + final MailSearchOpen open = searchOpenOf( + _item('m1', mailboxId: kInboxId, highlight: null), + _ready(), + ); + expect(open.highlight, const MessageHighlightQuery(q: '계약')); + expect(open.scopeMailboxId, kInboxId); + + final MailSearchOpen old = searchOpenOf( + _item('m1', mailboxId: kInboxId, supported: false), + _ready(all: true), + ); + expect(old.highlight, isNull); + expect(old.scopeMailboxId, isNull); + }); + }); +} diff --git a/flutter/test/features/mail/message_reader_page_test.dart b/flutter/test/features/mail/message_reader_page_test.dart index ce69581..9931a38 100644 --- a/flutter/test/features/mail/message_reader_page_test.dart +++ b/flutter/test/features/mail/message_reader_page_test.dart @@ -10,6 +10,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da import 'package:arasystem_app/features/mail/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart'; import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart'; @@ -172,6 +173,7 @@ void main() { required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }) => building.future, ), ); @@ -1099,7 +1101,12 @@ void main() { ), findsNothing, ); - expect(h.container.exists(messageReaderProvider('m1')), isFalse); + expect( + h.container.exists( + messageReaderProvider(const MessageReaderArgs('m1')), + ), + isFalse, + ); }); testWidgets('로그아웃하면 읽기 화면이 사라지고 늦은 읽음 응답은 알리지 않는다', ( diff --git a/flutter/test/features/mail/search_highlight_test.dart b/flutter/test/features/mail/search_highlight_test.dart new file mode 100644 index 0000000..23c91ce --- /dev/null +++ b/flutter/test/features/mail/search_highlight_test.dart @@ -0,0 +1,376 @@ +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 조각을 "[맞은 말]" 꼴의 글로 — 시험에서 보기 쉽게 +String _marked(String text, List? ranges) => [ + for (final HighlightSegment segment in highlightSegments(text, ranges)) + segment.mark ? '[${segment.text}]' : segment.text, +].join(); + +List _ranges(List<(int, int)> pairs) => [ + for (final (int start, int end) in pairs) (start: start, end: end), +]; + +/// 검색 강조 — 조각 나누기(짝: 웹 `search-highlight.ts`) · 응답 읽기 · 글 본문의 `` +void main() { + group('highlightSegments (강조 조각)', () { + test('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () { + expect(highlightSegments('계약서 초안', null), [ + (text: '계약서 초안', mark: false), + ]); + expect( + highlightSegments('계약서 초안', const []), + [(text: '계약서 초안', mark: false)], + ); + expect(highlightSegments('', null), isEmpty); + }); + + test('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () { + expect( + _marked('Re: 유지보수 계약 갱신 건', _ranges(<(int, int)>[(12, 14), (4, 8)])), + 'Re: [유지보수] 계약 [갱신] 건', + ); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + expect(_marked('견적서와 계약', _ranges(<(int, int)>[(5, 7)])), '견적서와 [계약]'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 3)])), '[계약서]'); + }); + + test('겹치거나 맞닿은 범위는 합친다', () { + expect(_marked('aaaa', _ranges(<(int, int)>[(0, 2), (1, 3)])), '[aaa]a'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2), (2, 3)])), '[계약서]'); + }); + + test('글 밖 · 거꾸로 된 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () { + expect(_marked('계약', _ranges(<(int, int)>[(5, 7)])), '계약'); + expect( + _marked('계약', _ranges(<(int, int)>[(1, 1), (2, 1), (-1, 1), (2, 5)])), + '계약', + ); + expect(_marked('계약', _ranges(<(int, int)>[(1, 10)])), '계[약]'); + }); + + test('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(2, 4)])), + '\u{1F600}[계약]', + ); + }); + + test('위치가 짝 글자(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(1, 4)])), + '[\u{1F600}계약]', + ); + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(0, 1)])), + '[\u{1F600}]계약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(2, 3)])), + '계[\u{1F600}]약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(0, 2)])), + '[계\u{1F600}]약', + ); + // 가른 자리를 넓힌 뒤 맞닿으면 합친다 + expect( + _marked('\u{1F600}\u{1F600}', _ranges(<(int, int)>[(0, 1), (3, 4)])), + '[\u{1F600}\u{1F600}]', + ); + final List segments = highlightSegments( + 'a\u{1F600}b\u{1F600}c', + _ranges(<(int, int)>[(2, 5)]), + ); + expect( + [for (final HighlightSegment s in segments) s.text], + ['a', '\u{1F600}b\u{1F600}', 'c'], + ); + }); + + test( + 'NFC 로 다시 맞추지 않는다 — 위치는 그 위치를 준 응답의 글에만 쓴다(서버가 강조가 있으면 NFC 로 싣는다)', + () { + // 같은 응답의 글(모은 한글)이면 맞고, 풀어 쓴 다른 글에 쓰면 글 그대로 자를 뿐이다(어긋남 — 그래서 쓰지 않는다) + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + const String nfd = '\u1100\u1168\u110B\u1163\u11A8'; + expect(highlightSegments(nfd, null).single.text, nfd); + }, + ); + }); + + group('parseSearchRanges (응답의 맞은 곳 읽기)', () { + test('[[시작, 끝], …] 을 읽는다 — 소수 부분 없는 수(2.0)는 정수다', () { + expect( + parseSearchRanges([ + [0, 2], + [4.0, 6], + ]), + _ranges(<(int, int)>[(0, 2), (4, 6)]), + ); + expect(parseSearchRanges([]), isEmpty); + }); + + test('정수가 아닌 위치는 그 칸만 버린다(웹 Number.isInteger) — 무한 · 아주 큰 수도', () { + expect( + parseSearchRanges([ + [0.5, 1], + [1, 2], + [double.infinity, 3], + [1e300, 1e300], + ]), + _ranges(<(int, int)>[(1, 2)]), + ); + }); + + test('모양이 틀리면 null — 목록이 아님 · 칸이 두 수가 아님', () { + expect(parseSearchRanges(null), isNull); + expect(parseSearchRanges('0,2'), isNull); + expect( + parseSearchRanges([ + [0], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, '2'], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, 1, 2], + ]), + isNull, + ); + expect(parseSearchRanges([0, 2]), isNull); + }); + }); + + group('검색 결과 항목 (MailSearchItem)', () { + Map item({Object? highlight = _absent}) => + { + ...messageJson('m1', mailboxId: kInboxId, subject: '유지보수 계약 갱신 건'), + if (!identical(highlight, _absent)) 'searchHighlight': highlight, + }; + + test('searchHighlight 칸이 없으면(14단계 앞 서버) 강조 없음 · 강조 쿼리도 싣지 않는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse(item())!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isFalse); + }); + + test('칸이 null 이면(엔진 방식) 강조 없음 — 그래도 14단계 서버다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item(highlight: null), + )!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + }); + + test('값이 있으면 제목 · 미리 보기 한 줄 · 그 맞은 곳을 읽는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item( + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + )!; + expect( + parsed.highlight, + MessageListHighlight( + subject: _ranges(<(int, int)>[(5, 7)]), + previewText: '…지난달 계약 기간에 맞춰', + previewRanges: _ranges(<(int, int)>[(5, 7)]), + ), + ); + }); + + test('강조의 모양이 틀리면 강조만 버리고 항목은 살린다 — 목록 칸이 틀리면 항목을 버린다', () { + for (final Object? broken in [ + 'x', + {'subject': [], 'preview': 'x'}, + { + 'subject': ['x'], + 'preview': {'text': 'a', 'ranges': []}, + }, + { + 'subject': [], + 'preview': {'text': 1, 'ranges': []}, + }, + ]) { + final MailSearchItem? parsed = MailSearchItem.tryParse( + item(highlight: broken), + ); + expect(parsed, isNotNull); + expect(parsed!.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + } + expect( + MailSearchItem.tryParse({...item(), 'isRead': 'x'}), + isNull, + ); + }); + }); + + group('읽기 화면의 강조 (MessageDetail.searchHighlight)', () { + Map detailJson(Object? highlight) => { + ...messageJson('m1', mailboxId: kInboxId), + 'textBody': '본문', + 'attachments': [attachmentJson('a1', name: '계약서.pdf')], + 'searchHighlight': highlight, + }; + + test('제목 · 첨부(맞은 첨부만) · 글 본문의 맞은 곳을 읽는다', () { + final MessageDetail detail = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [], + }), + )!; + expect( + detail.searchHighlight, + MessageDetailHighlight( + subject: _ranges(<(int, int)>[(0, 2)]), + attachments: >{ + 'a1': _ranges(<(int, int)>[(0, 2)]), + }, + ), + ); + }); + + test('없거나 모양이 틀리면 강조 없이 메일을 연다', () { + expect(MessageDetail.tryParse(detailJson(null))!.searchHighlight, isNull); + for (final Object? broken in [ + {'subject': [], 'textBody': []}, + { + 'subject': [], + 'attachments': {'a1': 'x'}, + 'textBody': [], + }, + ]) { + final MessageDetail? detail = MessageDetail.tryParse( + detailJson(broken), + ); + expect(detail, isNotNull); + expect(detail!.searchHighlight, isNull); + } + }); + + test('[이미지 보기]의 본문 바꾸기는 강조도 그 응답의 것으로, 요약 바꾸기는 강조를 그대로 둔다', () { + final MessageDetail first = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }), + )!; + final MessageDetail plain = MessageDetail.tryParse(detailJson(null))!; + expect(first.withBodyOf(plain).searchHighlight, isNull); + expect( + first.withSummary(plain.summary).searchHighlight, + first.searchHighlight, + ); + }); + }); + + group('강조 쿼리 · 준비 상태', () { + test('읽기 화면에 넘기는 쿼리는 빈 칸을 뺀 다섯 이름뿐이다', () { + expect( + const MessageHighlightQuery(q: '계약').toQueryParameters(), + {'q': '계약'}, + ); + expect( + const MessageHighlightQuery(q: '계약', subject: '견적').toQueryParameters(), + {'q': '계약', 'subject': '견적'}, + ); + expect(const MessageHighlightQuery().isEmpty, isTrue); + }); + + test('준비 상태는 building · ready 만 — 모르는 값은 null(안내 줄을 보이지 않는다)', () { + expect(SearchIndexStatus.parse('building'), SearchIndexStatus.building); + expect(SearchIndexStatus.parse('ready'), SearchIndexStatus.ready); + expect(SearchIndexStatus.parse('paused'), isNull); + expect(SearchIndexStatus.parse(null), isNull); + }); + }); + + group('글 본문의 · 본문 문서의 mark 모양', () { + test('맞은 곳은 속성 없는 — 글은 조각마다 이스케이프한다', () { + expect( + highlightedTextHtml( + '<계약> & 계약서', + _ranges(<(int, int)>[(1, 3), (7, 9)]), + ), + '<계약> & 계약서', + ); + expect(highlightedTextHtml('', const []), '<b>'); + }); + + test('글 본문이면 강조를 넣고, 서식 본문이면 넣지 않는다(서버가 이미 넣었다 — 앱의 정화가 mark 를 남긴다)', () { + final SanitizedMailBody text = mailBodyFragment( + htmlBody: null, + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(text.fragment, contains('계약서를')); + final SanitizedMailBody html = mailBodyFragment( + htmlBody: + '

계약서를 보냈습니다

', + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect( + html.fragment, + '

계약서를 보냈습니다

', + ); + }); + + test('서식 본문이 한도를 넘어 글 본문으로 보일 때도 글 본문의 위치로 강조한다', () { + final SanitizedMailBody omitted = mailBodyFragment( + htmlBody: '
${'
' * 4000}x${'
' * 4000}
', + textBody: '계약 안내', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(omitted.htmlOmitted, isTrue); + expect(omitted.fragment, contains('계약 안내')); + }); + + test('문서의 mark 는 웹과 같은 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1px', () { + expect( + buildMailDocument(''), + contains( + 'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}', + ), + ); + }); + }); +} + +const Object _absent = Object(); diff --git a/flutter/test/features/mail/search_text_test.dart b/flutter/test/features/mail/search_text_test.dart new file mode 100644 index 0000000..91d0b2d --- /dev/null +++ b/flutter/test/features/mail/search_text_test.dart @@ -0,0 +1,256 @@ +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ts_table.dart'; + +/// 서버 · 웹과 같은 표본 표 — 백엔드 파일을 그대로 읽는다(표는 하나 — 세 쪽 시험이 같은 표를 본다) +const String _samplesPath = '../backend/test/search-text-samples.ts'; + +/// 표의 한 행 — [넣은 글, 답] +List<(String, Object?)> _rows(String name) => <(String, Object?)>[ + for (final Object? row in readTsArrayFile(_samplesPath, name)) + if (row case [final String input, final Object? answer]) + (input, answer) + else + throw FormatException('표 $name 의 행 모양이 다르다: $row'), +]; + +/// 메일 검색의 낱말 나누기 · 접기 · 묶음 · 한 글자 판정 · 한도 (짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`) +void main() { + group('표 읽기 (test/support/ts_table.dart)', () { + test('작은따옴표 · 큰따옴표 글 · 이스케이프 · 주석 · 끝 쉼표 · 겹친 배열을 읽는다', () { + const String source = r''' +export const SAMPLE: readonly (readonly [a: string, b: readonly string[]])[] = [ + // 주석은 건너뛴다 + ['가\u3000나', ['\n', '\t', '\r', '\\', '\'', "\"", '\u{1F600}']], + /* 막힌 주석 */ ["끝", []], +]; +'''; + expect(readTsArray(source, 'SAMPLE'), [ + [ + '가\u3000나', + ['\n', '\t', '\r', r'\', "'", '"', '\u{1F600}'], + ], + ['끝', []], + ]); + }); + + test('모르는 꼴(템플릿 글 · 수)이나 없는 표는 실패한다 — 표의 모양이 바뀐 것을 알아챈다', () { + expect( + () => readTsArray('export const X = [`a`];', 'X'), + throwsFormatException, + ); + expect( + () => readTsArray('export const X = [1];', 'X'), + throwsFormatException, + ); + expect(() => readTsArray('', 'X'), throwsFormatException); + }); + + test('백엔드 표를 읽는다 — 특별한 행(풀어 쓴 한글 · \u0130 · BMP 밖 글자)이 들어 있다', () { + final List<(String, Object?)> fold = _rows('FOLD_SAMPLES'); + final List<(String, Object?)> grams = _rows('GRAM_SAMPLES'); + expect(fold.length, greaterThanOrEqualTo(10)); + expect(grams.length, greaterThanOrEqualTo(8)); + final List inputs = [ + for (final (String input, Object? _) in fold) input, + ]; + expect(inputs, contains('\u1112\u1161\u11AB\u1100\u1173\u11AF')); + expect(inputs, contains('\u0130stanbul')); + expect(inputs, contains('\u{10400}\u{1F600}')); + }); + }); + + group('foldText (접기 — 표 FOLD_SAMPLES)', () { + test('표의 모든 행 — 넣은 글을 접으면 답과 같다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(input), folded, reason: '행 ${input.runes.toList()}'); + } + }); + + test('접은 글은 (한글을 모은) 원문과 UTF-16 길이가 늘 같다 — BMP 의 모든 글자', () { + for (final (String input, Object? _) in _rows('FOLD_SAMPLES')) { + expect(foldText(input).length, composeHangul(input).length); + } + final List changedLength = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + final String char = String.fromCharCode(code); + if (foldText(char).length != char.length) changedLength.add(code); + } + expect(changedLength, isEmpty); + }); + + test('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(folded! as String), folded); + expect(foldText(foldText(input)), folded); + } + expect(foldText('a\uD800B'), 'a\uD800b'); + expect(foldText('\uDC00A'), '\uDC00a'); + }); + }); + + group('gramsOf (글자 두 개 묶음 — 표 GRAM_SAMPLES)', () { + test('표의 모든 행 — 나온 차례대로 겹치지 않게', () { + for (final (String fold, Object? grams) in _rows('GRAM_SAMPLES')) { + expect(gramsOf(fold).toList(), grams, reason: '행 $fold'); + } + }); + + test( + '공백은 정규식 \\s 와 같은 글자다(낱말 나누기와 같다) — Dart 의 trim 이 지우는 U+0085 는 아니다', + () { + final RegExp space = RegExp(r'\s', unicode: true); + final List differ = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + if (isSearchSpaceCode(code) != + space.hasMatch(String.fromCharCode(code))) { + differ.add(code); + } + } + expect(differ, isEmpty); + expect(isSearchSpaceCode(0x85), isFalse); + }, + ); + }); + + group('parseSearchTerms (검색어 나누기 — 짝: 백엔드 search-query.ts)', () { + List texts(String query) => [ + for (final SearchTerm term in parseSearchTerms(query)) term.text, + ]; + + test('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () { + expect(parseSearchTerms(' 서버 이전\u3000일정\n공유 '), [ + (kind: SearchTermKind.word, text: '서버'), + (kind: SearchTermKind.word, text: '이전'), + (kind: SearchTermKind.word, text: '일정'), + (kind: SearchTermKind.word, text: '공유'), + ]); + }); + + test('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () { + expect(parseSearchTerms('회의 "일정 공유" 서버'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정 공유'), + (kind: SearchTermKind.word, text: '서버'), + ]); + expect(texts('회의 "일정 공유'), ['회의', '일정', '공유']); + expect(parseSearchTerms('"a b" "c'), [ + (kind: SearchTermKind.phrase, text: 'a b'), + (kind: SearchTermKind.word, text: 'c'), + ]); + }); + + test('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () { + for (final String query in [ + '-', + '…', + '- … !!', + '"- …"', + '\u{1F600}', + '" "', + '""', + ]) { + expect(parseSearchTerms(query), isEmpty, reason: query); + } + expect(texts('회의, (회의) 회의. “회의” [공지] 2026. 회의!'), [ + '회의', + '공지', + '2026', + ]); + expect(parseSearchTerms('"회의, 일정."'), [ + (kind: SearchTermKind.phrase, text: '회의, 일정.'), + ]); + expect(parseSearchTerms('회의 회의 "일정" "일정" 일정'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정'), + (kind: SearchTermKind.word, text: '일정'), + ]); + }); + + test('글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다', () { + expect(texts('cafe\u0301!'), ['cafe\u0301']); + expect(texts('회의\u{1F600}\uFE0F'), ['회의']); + }); + + test('풀어 쓴 한글은 음절로 모으고 NUL 은 U+FFFD 로 바꾼다(서버의 색인과 같다)', () { + expect( + parseSearchTerms('\u1112\u1161\u11AB\u1100\u1173\u11AF'), + [(kind: SearchTermKind.word, text: '한글')], + ); + expect(parseSearchTerms('계\u0000약'), [ + (kind: SearchTermKind.word, text: '계\uFFFD약'), + ]); + }); + }); + + group('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () { + test('낱말이 모두 한 글자면 그렇다 — 여러 칸을 합쳐 본다', () { + expect(isSingleCharSearch(['김']), isTrue); + expect(isSingleCharSearch(['김 a 3']), isTrue); + expect(isSingleCharSearch(['김', '', '', '', '박']), isTrue); + // 한 글자 낱말로만 된 구절도 묶음이 없다 + expect(isSingleCharSearch(['"가 나"']), isTrue); + }); + + test('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () { + expect(isSingleCharSearch(['계약']), isFalse); + expect(isSingleCharSearch(['김 계약']), isFalse); + expect(isSingleCharSearch(['김', '', '', '견적', '']), isFalse); + expect(isSingleCharSearch(['"가나"']), isFalse); + // 풀어 쓴 한 글자도 모은 뒤 센다 + expect(isSingleCharSearch(['\u1100\u1161']), isTrue); + }); + + test('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다 — 앞뒤 문장부호는 뗀 뒤 센다', () { + expect(isSingleCharSearch([]), isFalse); + expect(isSingleCharSearch(['', ' ']), isFalse); + expect(isSingleCharSearch(['- …']), isFalse); + expect(isSingleCharSearch(['(김)']), isTrue); + }); + }); + + group('검색어 다듬기 · 한도', () { + test( + '앞뒤 공백(전각 공백 · 줄바꿈 포함)을 빼고 200자(UTF-16)에서 자른다 — U+0085 는 지우지 않는다(JS trim 과 같다)', + () { + expect(normalizeSearchText(' 계약 \u3000\n'), '계약'); + expect(normalizeSearchText('\u0085계약'), '\u0085계약'); + expect(normalizeSearchText('가' * 250).length, kMailSearchMaxLength); + expect(normalizeSearchText(''), ''); + expect(normalizeSearchText(' \u00A0 '), ''); + }, + ); + + test('이모지를 가운데서 자르게 되면 그 글자를 뺀다 · 자른 뒤 끝 공백도 뺀다', () { + final String cut = normalizeSearchText('${'가' * 199}\u{1F600}'); + expect(cut, '가' * 199); + expect(normalizeSearchText('${'가' * 199} 나').length, 199); + }); + + test('낱말 수는 따옴표와 상관없이 공백으로 나눈 조각이다(서버와 같은 기준)', () { + expect(countSearchWords('계약'), 1); + expect(countSearchWords(' "유지보수 계약" 갱신\u3000건 '), 4); + expect(countSearchWords(''), 0); + expect(countSearchWords('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), 9); + }); + }); + + group('composeHangul (한글 자모 모으기 — NFC 의 한글 부분)', () { + test('첫소리 + 가운뎃소리(+ 끝소리)를 음절로, 받침 없는 음절 + 끝소리도 모은다', () { + expect(composeHangul('\u1112\u1161\u11AB'), '한'); + expect(composeHangul('가\u11AB'), '간'); + expect(composeHangul('a\u1100\u1161b'), 'a가b'); + }); + + test('모을 것이 없으면 같은 글 · 짝이 없는 자모는 그대로 · 받침 있는 음절에는 끝소리를 더 붙이지 않는다', () { + const String plain = '계약서 \u{1F600}'; + expect(identical(composeHangul(plain), plain), isTrue); + expect(composeHangul('\u1100'), '\u1100'); + expect(composeHangul('\u1161'), '\u1161'); + expect(composeHangul('간\u11AB'), '간\u11AB'); + }); + }); +} diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index 8cd476c..9e91c08 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -166,6 +166,46 @@ class FakeMailBackend { final List<({String id, Object? images})> detailRequests = <({String id, Object? images})>[]; + /// 받은 GET /messages/{id} 의 쿼리 전체 (메일 id · 쿼리 — 검색 강조 쿼리를 본다) + final List<({String id, Map query})> detailQueries = + <({String id, Map query})>[]; + + /// 검색 강조 쿼리(`q`)로 물었을 때 덮는 칸 — 메일 id 마다(`searchHighlight` · NFC 로 실은 제목 등) + final Map> highlightDetails = + >{}; + + // ── 검색 (GET /messages?q=… · GET /search-index) ── + + /// 검색 응답 — 있으면 이것으로 답한다(쿼리를 받는다). 없으면 메일 제목 · 미리 보기에 q 가 든 메일을 한 쪽씩([defaultSearch]) + FakeResponse Function(Map query)? searchResponder; + + /// 기본 검색이 항목에 `searchHighlight` 칸을 싣는다(14단계 서버) — 값은 [searchHighlights] 에서(없으면 null) + bool searchHighlightField = false; + + /// 기본 검색 항목의 `searchHighlight` — 메일 id 마다 + final Map> searchHighlights = + >{}; + + /// 있으면 다음 검색 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? searchGate; + + /// `GET /search-index` 의 응답 — 기본은 준비됨 + FakeResponse searchIndex = FakeResponse.ok({ + 'status': 'ready', + 'done': 10, + 'total': 10, + }); + + /// 받은 검색 요청의 쿼리 (차례대로) + List> get searchQueries => >[ + for (final RequestOptions request in listRequests) + if (request.queryParameters.containsKey('q')) request.queryParameters, + ]; + + /// 받은 `GET /search-index` 의 수 + int get searchIndexRequests => + server.received('GET', MailApiPaths.searchIndex).length; + /// 받은 PATCH /messages/{id} (메일 id · 본문) final List<({String id, Map body})> singlePatches = <({String id, Map body})>[]; @@ -216,6 +256,18 @@ class FakeMailBackend { }); }); server.on('GET', MailApiPaths.messages, (RequestOptions options) async { + if (options.queryParameters.containsKey('q')) { + final Completer? gate = searchGate; + if (gate != null) { + searchGate = null; + await gate.future; + } + final FakeResponse Function(Map query)? responder = + searchResponder; + return responder == null + ? defaultSearch(options.queryParameters) + : responder(options.queryParameters); + } final Completer? gate = listGate; if (gate != null) { if (listGateOnce) listGate = null; @@ -301,9 +353,87 @@ class FakeMailBackend { if (failure != null) return failure; return FakeResponse.ok({'state': state}); }); + server.on('GET', MailApiPaths.searchIndex, (_) => searchIndex); _installReader(); } + /// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을 + /// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) — + /// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다. + /// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다 + FakeResponse defaultSearch(Map query) { + final String q = query['q']! as String; + final Object? mailboxId = query['mailboxId']; + final List> pool = >[ + for (final MapEntry>> entry + in messages.entries) + if (mailboxId == null + ? entry.key != kTrashId && entry.key != kJunkId + : entry.key == mailboxId) + ...entry.value, + ]; + if (mailboxId != null && + !mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> matched = + pool + .where( + (Map m) => + (m['subject']! as String).contains(q) || + (m['preview']! as String).contains(q), + ) + .toList() + ..sort((Map a, Map b) { + final int byTime = (b['receivedAt']! as String).compareTo( + a['receivedAt']! as String, + ); + return byTime != 0 + ? byTime + : (b['id']! as String).compareTo(a['id']! as String); + }); + final int limit = (query['limit'] as int?) ?? kMailPageSize; + final Object? cursor = query['cursor']; + final List> rest = cursor is String + ? matched.where(_afterSearchCursor(cursor)).toList() + : matched; + final List> page = rest.take(limit).toList(); + final bool hasNext = rest.length > page.length; + return FakeResponse.ok({ + 'items': >[ + for (final Map message in page) + { + ...message, + if (searchHighlightField) + 'searchHighlight': searchHighlights[message['id']], + }, + ], + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? searchCursorAfter(page.last) : null, + 'total': cursor == null ? matched.length : null, + }, + }); + } + + /// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽) + static String searchCursorAfter(Map message) => + 's:${message['receivedAt']}|${message['id']}'; + + /// 커서 자리 다음의 메일인가 + static bool Function(Map message) _afterSearchCursor( + String cursor, + ) { + final List parts = cursor.substring(2).split('|'); + final String at = parts[0]; + final String id = parts[1]; + return (Map message) { + final int byTime = (message['receivedAt']! as String).compareTo(at); + return byTime < 0 || + (byTime == 0 && (message['id']! as String).compareTo(id) < 0); + }; + } + static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$'); static final RegExp _attachmentPath = RegExp( r'^/messages/([^/]+)/attachments/([^/]+)$', @@ -319,6 +449,10 @@ class FakeMailBackend { final String id = _idOf(_messagePath, options.path); final Object? images = options.queryParameters[kMailImagesQuery]; detailRequests.add((id: id, images: images)); + detailQueries.add(( + id: id, + query: Map.of(options.queryParameters), + )); final Completer? gate = detailGate; if (gate != null) { detailGate = null; @@ -329,6 +463,7 @@ class FakeMailBackend { final Map? detail = detailJsonOf( id, showImages: images == kMailImagesShow, + highlighted: options.queryParameters.containsKey('q'), ); return detail == null ? FakeResponse.fail(404, 'RES_001') @@ -380,8 +515,13 @@ class FakeMailBackend { }); } - /// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails])를 덮는다. 없는 메일은 null - Map? detailJsonOf(String id, {bool showImages = false}) { + /// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails] · 강조 쿼리로 물었을 때 [highlightDetails])를 덮는다. + /// 없는 메일은 null + Map? detailJsonOf( + String id, { + bool showImages = false, + bool highlighted = false, + }) { final ({String mailboxId, Map message})? found = _find(id); if (found == null) return null; return { @@ -402,6 +542,7 @@ class FakeMailBackend { 'largeFiles': [], ...?details[id], if (showImages) ...?shownImageDetails[id], + if (highlighted) ...?highlightDetails[id], }; } @@ -418,6 +559,12 @@ class FakeMailBackend { /// 메일이 지금 든 메일함 (없으면 null) String? mailboxOf(String id) => _find(id)?.mailboxId; + /// 메일을 [mailboxId] 로 옮긴다(다른 화면 · 다른 기기에서 옮긴 것처럼) — 옮긴 뒤의 칸 + Map move(String id, String mailboxId) { + _apply(id, {'mailboxId': mailboxId}); + return _find(id)!.message; + } + /// 메일의 지금 칸 (없으면 null) Map? messageOf(String id) => _find(id)?.message; diff --git a/flutter/test/support/ts_table.dart b/flutter/test/support/ts_table.dart new file mode 100644 index 0000000..04de897 --- /dev/null +++ b/flutter/test/support/ts_table.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +/// 모노레포의 TypeScript 표본 표를 읽는다 — 시험 전용(앱 코드에서 쓰지 않는다). +/// +/// `export const NAME … = [` 부터 짝이 맞는 `]` 까지를 풀어 글(String) · 목록(List)의 나무로 돌려준다. +/// 읽는 것: 작은따옴표 · 큰따옴표 글, `\u` · `\u{…}` · `\n` · `\t` · `\r` · `\0` · `\\` · `\'` · `\"` 이스케이프, 대괄호 · 쉼표(끝 쉼표 포함) · `//` 주석 · `/* */` 주석. +/// 줄바꿈 모양(prettier)이 바뀌어도 읽는다. 그 밖의 꼴(템플릿 글 · 수 · 변수)이 나오면 [FormatException] — 표의 모양이 바뀐 것을 시험이 알아챈다. +List readTsArray(String source, String name) { + final int at = source.indexOf('export const $name'); + if (at < 0) throw FormatException('표 $name 이 없다'); + final int open = source.indexOf('= [', at); + if (open < 0) throw FormatException('표 $name 의 배열이 없다'); + final _TsReader reader = _TsReader(source, open + 2); + final Object? value = reader.readValue(); + if (value is! List) throw FormatException('표 $name 이 배열이 아니다'); + return value; +} + +/// [path](flutter/ 에서 본 상대 경로)의 TS 파일에서 [name] 표를 읽는다 — `flutter test` 는 flutter/ 에서 돈다 +List readTsArrayFile(String path, String name) => + readTsArray(File(path).readAsStringSync(), name); + +class _TsReader { + _TsReader(this._source, this._index); + + final String _source; + int _index; + + Object? readValue() { + _skip(); + _expectMore(); + final String char = _source[_index]; + if (char == '[') return _readArray(); + if (char == "'" || char == '"') return _readString(); + throw FormatException('모르는 값', _source, _index); + } + + List _readArray() { + _index++; + final List items = []; + while (true) { + _skip(); + _expectMore(); + if (_source[_index] == ']') { + _index++; + return items; + } + items.add(readValue()); + _skip(); + _expectMore(); + final String next = _source[_index]; + if (next == ',') { + _index++; + } else if (next != ']') { + throw FormatException('쉼표나 ] 가 와야 한다', _source, _index); + } + } + } + + String _readString() { + final String quote = _source[_index]; + _index++; + final StringBuffer out = StringBuffer(); + while (true) { + _expectMore(); + final String char = _source[_index]; + if (char == quote) { + _index++; + return out.toString(); + } + if (char == '\n' || char == '\r') { + throw FormatException('글 안에 줄바꿈', _source, _index); + } + if (char != r'\') { + out.write(char); + _index++; + continue; + } + _index++; + _expectMore(); + final String escape = _source[_index]; + _index++; + switch (escape) { + case 'n': + out.write('\n'); + case 't': + out.write('\t'); + case 'r': + out.write('\r'); + case '0': + out.write('\u0000'); + case r'\': + case "'": + case '"': + out.write(escape); + case 'u': + out.writeCharCode(_readUnicodeEscape()); + default: + throw FormatException('모르는 이스케이프 \\$escape', _source, _index); + } + } + } + + /// `\u` 뒤 — `{1F600}` 또는 네 자리 16진수 + int _readUnicodeEscape() { + _expectMore(); + if (_source[_index] == '{') { + final int close = _source.indexOf('}', _index); + if (close < 0) throw FormatException('닫히지 않은 \\u{', _source, _index); + final String hex = _source.substring(_index + 1, close); + _index = close + 1; + return int.parse(hex, radix: 16); + } + if (_index + 4 > _source.length) { + throw FormatException('짧은 \\u', _source, _index); + } + final String hex = _source.substring(_index, _index + 4); + _index += 4; + return int.parse(hex, radix: 16); + } + + /// 공백 · 주석을 건너뛴다 + void _skip() { + while (_index < _source.length) { + final String char = _source[_index]; + if (char == ' ' || char == '\n' || char == '\r' || char == '\t') { + _index++; + } else if (_source.startsWith('//', _index)) { + final int end = _source.indexOf('\n', _index); + _index = end < 0 ? _source.length : end + 1; + } else if (_source.startsWith('/*', _index)) { + final int end = _source.indexOf('*/', _index + 2); + if (end < 0) throw FormatException('닫히지 않은 주석', _source, _index); + _index = end + 2; + } else { + return; + } + } + } + + void _expectMore() { + if (_index >= _source.length) { + throw FormatException('표가 중간에 끝났다', _source, _index); + } + } +} -- 2.52.0 From 97a989be0735472632c2d6432fbaf922c31ec12b Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 12:19:45 +0900 Subject: [PATCH 17/32] =?UTF-8?q?chore:=20=EC=95=B1=20=EC=A7=9D=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9D=B4=20=EC=9D=BD=EB=8A=94=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=ED=91=9C=20=EB=91=90=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=B4=20=EB=B0=94=EB=80=8C=EC=96=B4=EB=8F=84=20Flutter=20CI?= =?UTF-8?q?=20=EB=A5=BC=20=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 첨부 형식 표(attachment.policy.ts)와 검색 표본 표(search-text-samples.ts)만 고친 커밋에서는 앱 시험이 깨진 채로 남고 다음 flutter 커밋에서야 드러났다. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-flutter.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci-flutter.yml b/.github/workflows/ci-flutter.yml index 1ab1353..aa9196d 100644 --- a/.github/workflows/ci-flutter.yml +++ b/.github/workflows/ci-flutter.yml @@ -7,6 +7,8 @@ name: CI (Flutter) # # 언제 도는가: # - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로) +# - 앱 짝 시험이 그대로 읽는 백엔드 파일이 바뀐 푸시·PR — 첨부 형식 표(attachment.policy.ts) · 검색 표본 표(search-text-samples.ts). +# 그 파일만 고친 커밋에서도 앱 시험이 깨지는지 본다 (2026-11-07) # - Actions 탭의 "Run workflow" 버튼으로 수동 실행 # # 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다. @@ -16,10 +18,14 @@ on: branches: [main] paths: - 'flutter/**' + - 'backend/src/modules/mail/attachment.policy.ts' + - 'backend/test/search-text-samples.ts' - '.github/workflows/ci-flutter.yml' pull_request: paths: - 'flutter/**' + - 'backend/src/modules/mail/attachment.policy.ts' + - 'backend/test/search-text-samples.ts' - '.github/workflows/ci-flutter.yml' workflow_dispatch: -- 2.52.0 From 4ca2d41bad337e368e0d2005de0d57d508d09263 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 12:19:45 +0900 Subject: [PATCH 18/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?1=EC=B0=A8=EC=9D=98=20=EA=B5=AC=ED=98=84=ED=95=98=EB=A9=B0=20?= =?UTF-8?q?=EC=A0=95=ED=95=9C=20=EA=B2=83=EA=B3=BC=20=EC=A7=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 디자인 README 「검색」 절, flutter 스킬 표 · 함정 두 줄 · CI 경로, 계약 2절 "검색 — 14단계"의 앱 클라이언트 줄과 목록 항목의 searchHighlight 칸이 늘 있다는 줄, 두 표본 표 머리말(앱 시험이 백엔드 표를 읽는다)을 적는다. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 7 +++- backend/test/search-text-samples.ts | 2 + docs/design/app/README.md | 38 ++++++++++++++++++- docs/mail/api-contract.md | 12 +++++- .../src/test-utils/search-text-samples.ts | 1 + 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index e426177..8965f1f 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -28,6 +28,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` | | 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) | | **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` | +| **메일 검색 (`App1Search` — 1차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar` · `mail_search_states` · `highlighted_text`) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | @@ -291,9 +292,9 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고, Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다). Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. - CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). + CI(`ci-flutter.yml`)는 **flutter/ 와 앱 짝 시험이 읽는 백엔드 파일 둘(`attachment.policy.ts` · `test/search-text-samples.ts`)이 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. -- [ ] **위젯 · 시험 함정**(쓰기 화면에서 찾음 — 2026-11-07): +- [ ] **위젯 · 시험 함정**(쓰기 · 검색 화면에서 찾음 — 2026-11-07): `TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다. go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다. `ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`). @@ -302,6 +303,8 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`). 무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다. 가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다. + InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄). + 두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`). - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). - [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`). `.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다. diff --git a/backend/test/search-text-samples.ts b/backend/test/search-text-samples.ts index bbbbfc2..1a48fdd 100644 --- a/backend/test/search-text-samples.ts +++ b/backend/test/search-text-samples.ts @@ -4,6 +4,8 @@ * * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). + * 앱 시험(`flutter/test/features/mail/search_text_test.dart`)도 이 파일의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다(작은따옴표 글의 배열 · 이스케이프 · 주석만 읽는다) + * — 표의 모양을 바꾸면 앱 시험도 본다(이 파일이 바뀌면 Flutter CI 도 돈다). * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다). */ diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 2b5adb4..d60d92e 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -64,7 +64,7 @@ - 붙잡힌 메일(보내기 취소 · 예약)은 고를 수 없다 — 누르면 "고를 수 없습니다" 알림, 화면 낭독기는 꺼진 칸의 까닭을 읽는다. 보낼 시각 + 2초에 다시 보고 남아 있으면 5초마다 세 번 더(서버 시각으로 잰다 · 실패해도 이어 본다). - 알림: [되돌리기]가 있는 알림은 [메일 쓰기] 바로 아래 한 줄(10초 — 화면 낭독기를 켠 동안은 저절로 닫지 않는다), 단추 없는 알림은 전역 스낵바로 띄워 [되돌리기] 줄을 바꿔 끼우지 않는다. [메일 쓰기]는 알림이 뜨면 함께 올라간다. - 서랍은 메일함을 바꿔 끼운다(방문 기록을 쌓지 않는다 — 다른 메일함은 받은편지함 위에 하나만). 단추로만 연다(가장자리 밀기 없음). 메일함 목록을 받기 전에는 뼈대, 받지 못했으면 [다시 시도]. - - 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 아직 "메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요." + - 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 검색 화면을 연다(아래 「검색」). - 새 메일 확인: 화면이 보이고(다른 탭 · 전체 화면에 가리지 않음) 앱이 앞에 있을 때만 1분마다, 다시 보이면 10초가 지났을 때 바로(웹 3단계). 가려진 목록은 바뀜을 적어 두었다가 보일 때 맞춘다. 다시 받은 쪽이 메일함 끝까지면 그 밖의 옛 메일은 다른 곳에서 지운 것으로 보고 남기지 않는다(웹은 남긴다 — 서버의 hasNext 는 끝까지 받았을 때만 거짓이다). 끝 표시는 보이는 동안 다음 쪽을 이어 부르고, 같은 커서가 되풀이되면 [다시 시도]로 멈춘다. - 크기: 탭 막대의 고른 탭 알약은 Material 3 그대로 64×32(보드 56×32), 글자는 테마 역할로 맞췄다(12.5 → 12 · 13.5 → 13 · 14.5 → 15 · 17 → 18, 굵기 650 → 700 · 450 → 500). 탭 막대는 위 안전 영역을 빼고, 고르는 중 알림 줄은 시스템 내비게이션 위에 뜬다. @@ -182,8 +182,42 @@ 고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분), 실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글. +### 검색 (`App1Search` · `App1SearchStates` — 1차, 2026-11-07 확정) + +웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 조건으로 찾기(바닥 시트)는 2차다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로"). + +- **자리**: 목록 위 막대의 [찾기]가 연다. 탭 안 화면 `/mail/search` 라 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫히고, 다른 탭에 다녀오면 결과가 남는다. 검색어는 주소에 싣지 않는다(시작한 메일함만 라우트 `extra`). +- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다. 찾으면 자판을 내리고, 결과를 끌어도 내린다. + 칸은 글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표와 대시 끔 · 두 줄까지 · 줄바꿈은 띄어쓰기 · UTF-16 200자다. 칸 이름은 낭독에서 한 번만 읽는다(보이는 안내 글은 낭독에서 뺀다). +- **찾을 곳 줄 56**: "찾을 곳" + [시작한 메일함 | 전체 메일함](고른 칸은 옅은 청록 바탕 · 굵은 글, ✓ 없음). 찾기 전부터 보이고, 바꾸면 칸의 글로 바로 다시 찾는다. 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". 휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다. +- **결과 머리 36**(최소 — 큰 글자면 늘어난다): "‘계약’ · 4통" · 찾는 동안 "· 찾는 중" · 전체 수를 모르면 검색어만. 그 아래 안내 줄은 하나다(검색 준비 중 또는 한 글자 안내 — 함께 뜨지 않고, 준비 상태를 받지 못하면 없다). +- **결과 줄**은 목록 줄 그대로이고 고르지 않는다(사진은 단추가 아니고, 길게 눌러도 아무 일 없다). + - 맞은 말은 노랑 #FCE7A4(`AppColors.searchMark` — 짝: 웹 `--color-search-mark`) 바탕이다. 회색 미리 보기 줄 안의 강조 글자만 `textSecondary`(#4A5363 — 노랑 위 6.3:1)다. + - 강조가 있으면 미리 보기는 서버의 맞은 말 둘레 한 줄이다. + - 전체 메일함이면 시각 앞에 메일함 이름(시각과 같은 글자 · 글 줄 폭의 40%까지)을 두고, 보낸편지함 · 임시보관함은 "받는 사람: …"이다. +- **결과에서 읽기**: 누르면 읽기 화면이다(임시보관함은 쓰기 화면). 머리의 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다(본문 mark 는 웹과 같다 — 글자색 그대로 · 모서리 2 · 좌우 1px). 지우고 돌아오면 결과 아래에 [되돌리기] 알림이 뜬다. +- **이어 찾기**: 한 차례 5번 · 20통이다. 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면, 있으면 목록 끝 줄). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 저절로 묻는다. +- **결과 갱신**: 새 메일 확인으로 다시 찾지 않는다. 읽기 화면이 바꾼 표시 · 메일함 · 붙잡힘만 덮고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. 당겨서 새로고침은 다시 찾기다. +- **구현하며 정한 것**(2026-11-07) + - 길게 누르기: InkWell 에 길게 누르기 처리가 없으면 뗄 때 누르기로 읽어 메일이 열린다 — 검색 줄은 GestureDetector 가 받아 두고 아무것도 하지 않는다(진동 없이). 줄의 낭독 동작은 `MessageRow` 바깥 Semantics 에 둔다(목록 줄도 같다). + - 같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 둔 채 찾고, 그동안 머리를 "· 찾는 중"으로 둔다. 실패하면 결과를 그대로 두고 알린다. + - 찾을 곳 바꾸기 · [전체 메일함에서 찾기]는 칸이 비었어도 결과가 보이면 그 결과의 검색어로 다시 찾고 자판을 내린다(결과와 찾을 곳이 어긋나지 않게 — 계획은 "범위만 바꾼다"였다). 결과도 없을 때만 곳만 바꿔 둔다. + - 이어 찾기 실패: 하나도 찾기 전의 실패는 받지 못함 화면, 하나라도 찾은 뒤의 실패는 알림 + 끝 줄 [다시 시도], 0통 화면의 [더 찾기] 실패는 알림 + [더 찾기] 그대로. 같은 커서가 새 메일 없이 돌아오면 결과가 있을 때 [다시 시도], 없을 때 [더 찾기]. + - [되돌리기]로 돌아온 메일은 늘 받은 시각 차례의 그 자리에 넣고 수도 되돌린다(불러온 끝 줄이어도 — 서버 커서는 그 줄 다음부터 준다). 다음 쪽의 겹침은 지금 목록의 id 로만 막는다. + - 결과의 붙잡힌 메일(보내기 취소 · 예약)은 목록처럼 가장 이른 보낼 시각 + 2초(서버 시계 — 검색 응답의 `Date`)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다. 시각이 지난 채 남으면 5초마다 3번 더 보고, 알리지 않으며, 없어졌으면(RES_001) 뺀다. + - 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면). + - 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다). + - 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16. + - 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어의 낱말은 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다. +- **알려진 한계** + - 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다). + - Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다. + - 보낸 사람이 본문에 쓴 `` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다. +- **실기기에서 볼 것**: 두 줄 칸 + 글 자판의 [검색] 키(삼성 · Gboard), 한글 조합 중 [검색], 하드웨어 자판 Enter, 여러 줄 붙여 넣기 · 삼성 둥근 따옴표, 결과를 끌어 자판이 내려가는지, 자판이 열릴 때 탭 막대 · [되돌리기] 알림 자리, + 큰 글자에서 전환 칸 · 개수 줄 · 결과 줄 메일함 이름, TalkBack 차례 · 개수 줄 읽기 · 칸 이름 한 번 · 찾을 곳 고름 상태 · [더 찾기] 꺼짐, 노랑 대비 · WebView mark, 큰 메일함의 [더 찾기] 흐름, 길게 누르기가 진동 없이 아무 일도 없는지, + 보내기 취소를 기다리는 메일의 "보내는 중"이 풀리는지, 앱 로그인 서버(feat/app-auth) + 14단계 서버로 실제 검색 · 강조 · 준비 상태(앱만 쓰는 직원의 색인이 시작되는지). ### 새 메일 알림 (푸시) - 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). ### 처음 판에서 미룬 것 -서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. +서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 조건으로 찾기(2차)와 결과에서 여러 통 고르기를 미뤘다. diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index a6c8074..82ba39a 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -730,7 +730,7 @@ interface MailState { 함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다. - 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계). - **응답 (목록)** - - 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. + - 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. 칸은 늘 싣는다(값이 없으면 `null`) — 앱은 이 칸이 있는지로 14단계 서버를 알아보고 읽기 화면에 강조 쿼리를 싣는다. 위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다. 본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다). ① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지. @@ -762,6 +762,16 @@ interface MailState { 그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다. 목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게). - **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다. +- **클라이언트 — 앱**(Flutter 1차 — 앱 디자인 README 「검색」, 2026-11-07) + - 검색어 · 찾을 곳을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다. + - 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개)를 먼저 본다. 낱말이 8개를 넘으면 묻지 않고 결과 자리에 "찾을 말이 너무 많습니다"를 보인다(웹 검색 칸은 `VAL_001` 사전 문구 알림). + [✕]는 검색어와 결과를 지운다(웹의 ✕ 는 검색을 닫는다 — 앱은 [←] · 뒤로 가기가 닫는다). + - 이어 찾기는 웹과 같이 한 차례 5번 · 20통이다. 다만 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 묻는다. + - 결과는 새 메일 확인으로 다시 찾지 않는다. 앱의 읽기 화면이 바꾼 것과 붙잡힌 메일의 풀림(보낼 시각 + 2초에 그 줄만 `GET /api/messages/{id}` — 강조 쿼리 없이)만 반영하고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. + - 읽기 화면의 강조 쿼리(`q`)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다. + - 강조 위치는 NFC 로 다시 맞추지 않고 그 위치를 준 응답의 글(목록은 그 항목의 제목 · `preview.text`, 읽기 화면은 강조 응답의 제목 · 첨부 이름 · textBody)에만 쓴다(Dart 에 NFC 가 없다). + 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다 — 글자는 같고 목록이 다시 맞추면 돌아온다. + - `GET /api/search-index` 는 찾을 때마다 알림 없이 묻는다 — 이 요청으로 앱만 쓰는 직원의 색인도 시작된다. - 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`). ### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기) diff --git a/frontend/src/test-utils/search-text-samples.ts b/frontend/src/test-utils/search-text-samples.ts index 10054f9..6315e9c 100644 --- a/frontend/src/test-utils/search-text-samples.ts +++ b/frontend/src/test-utils/search-text-samples.ts @@ -4,6 +4,7 @@ /** * ⚠️ backend `test/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). + * 앱 시험(`flutter/test/features/mail/search_text_test.dart`)은 backend 표의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다 — 이 표와 행이 같으니 고칠 때 세 쪽이 함께 본다. * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. */ -- 2.52.0 From 23afa583c109ffbfb9066722b22702caae3bb1ff Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:04:59 +0900 Subject: [PATCH 19/32] =?UTF-8?q?fix(flutter):=20=EC=95=B1=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=2018=EA=B3=B3=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=EC=9D=B4=20=EB=A7=90=ED=95=98=EB=93=AF=20?= =?UTF-8?q?=EB=8B=A4=EB=93=AC=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고 짚은 글을 웹과 같은 기준으로 고친다. 화면이 하는 일을 설명하는 말("여기에 표시됩니다")과 번역투("맞는 메일")를 빼고, 빈 메일함 안내 · 읽기 화면 본문 안내 · 검색 글 · 로그인 글을 내 메일이 어떻게 되는지와 할 일로 쓴다. 본문을 다 보이지 못할 때는 PC 웹메일에서 원문을 받는 길까지 적고, 사용자가 만든 메일함의 빈 안내는 앱의 [이동]이 아이콘이라 길게 눌러 고르는 말로 쓴다. 오류 사전은 이 브랜치를 main 위로 옮길 때 새 글로 다시 만든다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/constants/login_texts.dart | 6 +-- .../mail/presentation/mail_texts.dart | 40 ++++++++++--------- .../widgets/mail_search_states.dart | 2 +- .../features/mail/mail_list_page_test.dart | 2 +- .../test/features/mail/mail_rules_test.dart | 13 ++++++ 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/flutter/lib/core/constants/login_texts.dart b/flutter/lib/core/constants/login_texts.dart index 6f4e0d8..564b54a 100644 --- a/flutter/lib/core/constants/login_texts.dart +++ b/flutter/lib/core/constants/login_texts.dart @@ -14,7 +14,7 @@ abstract final class LoginTexts { static const String brandHeadline = '사내 업무를 한 계정으로'; /// 브랜드 띠의 소개 글 - static const String brandLead = '메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 이어집니다.'; + static const String brandLead = '메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.'; static const String subtitle = '회사 메일 주소(@$kCompanyMailDomain)로 로그인해 주세요.'; @@ -44,7 +44,7 @@ abstract final class LoginBandTexts { abstract final class SupportLineTexts { /// 첫 단계 static const String passwordQuestion = '비밀번호를 잊으셨나요?'; - static const String passwordFallback = '비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.'; + static const String passwordFallback = '비밀번호를 잊으셨다면 관리자에게 문의해 주세요.'; /// 코드 단계 · 2단계 인증 켜기 static const String authenticatorQuestion = '인증 앱을 쓸 수 없나요?'; @@ -110,7 +110,7 @@ abstract final class PasswordChangeTexts { /// 넷째 단계 — 2단계 인증 켜기 (제한 `twoFactorSetup`). 같은 휴대폰이라 QR 대신 [인증 앱에 추가] · 키 · [복사] abstract final class TwoFactorSetupTexts { static const String subtitle = - '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.'; + '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다.'; /// ① 인증 앱에 추가 static const String addTitle = '인증 앱에 추가해 주세요'; diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 980a60b..67b0262 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -211,7 +211,7 @@ abstract final class MailReaderTexts { // ── 안내 줄 ── static const String imagesBlocked = - '외부 이미지를 차단했습니다. 개인정보 보호를 위해 보낸 사람 서버의 이미지는 불러오지 않습니다.'; + '외부 이미지는 막아 두었습니다. 이미지를 불러오면 보낸 사람이 내가 메일을 열어 본 것을 알 수 있습니다.'; static const String showImages = '이미지 보기'; /// 그림을 받아 넣는 동안 — 화면 낭독기 (앱에서 새로 씀) @@ -219,9 +219,11 @@ abstract final class MailReaderTexts { /// 본문 안내 (웹 `MAIL_BODY_NOTICES`) static String bodyNotice(MailBodyNotice notice) => switch (notice) { - MailBodyNotice.htmlOmitted => '본문이 복잡해 서식 없이 글자만 표시합니다.', - MailBodyNotice.unavailable => '본문이 너무 크거나 복잡해 표시할 수 없습니다.', - MailBodyNotice.truncated => '본문이 길어 앞부분만 표시합니다.', + MailBodyNotice.htmlOmitted => '본문이 복잡해 서식 없이 글자만 보여 드립니다.', + MailBodyNotice.unavailable => + '본문이 너무 크거나 복잡해 앱에서는 열 수 없습니다. PC 웹메일에서 원문을 내려받아 볼 수 있습니다.', + MailBodyNotice.truncated => + '본문이 길어 앞부분만 보여 드립니다. 전체는 PC 웹메일에서 원문을 내려받아 볼 수 있습니다.', }; /// 보내기 취소를 기다리는 메일 (앱에서 새로 씀 — 웹 읽기 화면에는 이 줄이 없다) @@ -365,15 +367,16 @@ abstract final class MailListTexts { static String emptyTitle(String mailboxName) => '$mailboxName${subjectParticle(mailboxName)} 비어 있습니다'; - /// 메일함마다 빈 메일함 안내 — 키 null 은 사용자가 만든 메일함 + /// 메일함마다 빈 메일함 안내 — 키 null 은 사용자가 만든 메일함. + /// 그 글은 앱 글이다(웹과 다르다) — 앱의 [이동]은 글 없는 아이콘이고 고르기는 길게 누르기라서(2026-11-07) static String emptyHint(MailboxRole? role) => switch (role) { - MailboxRole.inbox => '새 메일이 오면 여기에 표시됩니다.', - MailboxRole.drafts => '임시 저장한 메일이 여기에 표시됩니다.', - MailboxRole.sent => '보낸 메일이 여기에 표시됩니다.', - MailboxRole.junk => '스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.', - MailboxRole.trash => '삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.', - MailboxRole.archive => '보관한 메일이 여기에 표시됩니다.', - null => '이 메일함으로 옮긴 메일이 여기에 표시됩니다.', + MailboxRole.inbox => '새 메일이 오면 여기로 들어옵니다.', + MailboxRole.drafts => '쓰다가 임시 저장한 메일은 여기에 남습니다.', + MailboxRole.sent => '보낸 메일은 여기에 남습니다.', + MailboxRole.junk => '스팸 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.', + MailboxRole.trash => '지운 메일은 여기에 30일 동안 남아 있다가 저절로 지워집니다.', + MailboxRole.archive => '오래 두고 볼 메일을 여기로 옮겨 보관할 수 있습니다.', + null => '다른 메일함에서 메일을 길게 눌러 고른 뒤 이 메일함으로 옮길 수 있습니다.', }; /// 휴지통 · 스팸메일함 목록 위의 30일 줄 (웹 13단계 — 메일이 없으면 빈 안내에 붙인다) @@ -428,15 +431,16 @@ abstract final class MailSearchTexts { // ── 안내 줄 (웹 `SEARCH_NOTICE_TEXT`) ── /// 지난 메일의 색인을 만드는 동안(`GET /search-index` 의 building) static const String indexing = - '지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.'; + '지난 메일까지 낱말의 일부로도 찾을 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말을 끝까지 다 써야 찾을 수 있습니다.'; /// 한 글자 낱말뿐이라 본문은 찾지 않았을 때(색인이 준비됐을 때만) static const String singleChar = '한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.'; // ── 결과 없음 ── - /// "‘해지 통보’에 맞는 메일이 없습니다" - static String emptyTitle(String query) => '‘$query’에 맞는 메일이 없습니다'; + /// "‘해지 통보’가 들어간 메일이 없습니다" + static String emptyTitle(String query) => + '‘$query’${subjectParticle(query)} 들어간 메일이 없습니다'; /// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차) static const String emptyHint = '다른 말로 찾아 보세요.'; @@ -449,7 +453,7 @@ abstract final class MailSearchTexts { static const String searchAll = '전체 메일함에서 찾기'; // ── 아직 못 찾음 · 목록 끝 ── - static const String partialTitle = '아직 맞는 메일을 찾지 못했습니다'; + static const String partialTitle = '아직 찾은 메일이 없습니다'; static const String partialHint = '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.'; static const String more = '더 찾기'; @@ -460,9 +464,9 @@ abstract final class MailSearchTexts { // ── 찾을 말이 너무 많음 ── static const String tooManyWordsTitle = '찾을 말이 너무 많습니다'; - /// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색어와 조건에 넣은 낱말은 모두 합쳐 …" + /// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 …" static String tooManyWordsHint(int max) => - '검색어의 낱말은 ${formatCount(max)}개까지 찾을 수 있습니다. 낱말을 줄여 주세요.'; + '검색어에는 낱말을 ${formatCount(max)}개까지 넣을 수 있습니다. 낱말을 몇 개 줄여 주세요.'; // ── 찾기 전 ── /// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀) diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart index eb91f13..e928c1f 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -36,7 +36,7 @@ class MailSearchIntro extends StatelessWidget { } } -/// 결과 없음 — "‘해지 통보’에 맞는 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기] +/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기] class MailSearchEmpty extends StatelessWidget { const MailSearchEmpty({ required this.query, diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index 4f320ce..fbbc95d 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -676,7 +676,7 @@ void main() { expect(findCopy('휴지통이 비어 있습니다'), findsOneWidget); expect( - findCopy('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.'), + findCopy(MailListTexts.emptyHint(MailboxRole.trash)), findsOneWidget, ); expect(find.byType(MailRetentionNotice), findsNothing); diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart index d87ebd7..9963852 100644 --- a/flutter/test/features/mail/mail_rules_test.dart +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -86,6 +86,19 @@ void main() { expect(subjectParticle('Inbox'), '이(가)'); expect(MailListTexts.emptyTitle('휴지통'), '휴지통이 비어 있습니다'); expect(MailListTexts.emptyTitle('프로젝트'), '프로젝트가 비어 있습니다'); + // 검색 결과 없음 — 따옴표로 묶은 검색어 뒤에 붙인다 + expect( + MailSearchTexts.emptyTitle('계약'), + startsWith('${MailSearchTexts.quoted('계약')}이 '), + ); + expect( + MailSearchTexts.emptyTitle('해지 통보'), + startsWith('${MailSearchTexts.quoted('해지 통보')}가 '), + ); + expect( + MailSearchTexts.emptyTitle('ARA'), + startsWith('${MailSearchTexts.quoted('ARA')}이(가) '), + ); }); test('으로/로 — 받침 · ㄹ 받침 · 받침 없음 · 한글이 아님', () { -- 2.52.0 From ca6c68c34f7c6ff871734fec2be70c650acaab76 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:04:59 +0900 Subject: [PATCH 20/32] =?UTF-8?q?docs:=20=EC=95=B1=20README=20=EC=97=90=20?= =?UTF-8?q?=EC=98=AE=EA=B2=A8=20=EC=A0=81=EC=9D=80=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B8=80=EC=9D=84=20=EC=83=88=20=EB=AC=B8=EA=B5=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/design/app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/app/README.md b/docs/design/app/README.md index d60d92e..47948f1 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -208,7 +208,7 @@ - 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면). - 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다). - 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16. - - 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어의 낱말은 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다. + - 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어에는 낱말을 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다. - **알려진 한계** - 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다). - Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다. -- 2.52.0 From 3278e1abc1843c4dac0d8adbfcd54ca68a1da9c8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:42:43 +0900 Subject: [PATCH 21/32] =?UTF-8?q?feat(flutter):=20=EC=A1=B0=EC=82=AC=20?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=EB=AF=B8=EA=B0=80=20=ED=98=B8=ED=99=98=20?= =?UTF-8?q?=EC=9E=90=EB=AA=A8=20=C2=B7=20=EC=88=AB=EC=9E=90=EC=9D=98=20?= =?UTF-8?q?=EB=B0=9B=EC=B9=A8=EB=8F=84=20=EA=B3=A0=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 조사 도우미(frontend shared/utils/korean.ts)와 짝으로 맞춘다. 초성 검색어(‘ㄱㅁ’)나 숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나오지 않게, 호환 자모 자음은 받침 있음 (ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는 소리(영 · 일 · 이 …)로 고른다. 라틴 글자 · 기호로 끝나면 지금처럼 괄호 꼴이다. 알려진 한계: 라틴 글자 뒤의 숫자도 한국어 소리로 센다(‘녹음.mp3’은). Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/utils/korean_text.dart | 102 +++++++++++++----- .../mail/presentation/mail_texts.dart | 2 +- .../mail/compose_attachments_test.dart | 2 +- .../test/features/mail/mail_rules_test.dart | 43 ++++++++ 4 files changed, 123 insertions(+), 26 deletions(-) diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 41a7c68..78349e2 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -39,39 +39,93 @@ const int _noJongseong = 0; /// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로) const int _jongseongRieul = 8; -/// 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글) null -int? _lastJongseong(String word) { +/// 한글 호환 자모의 자음 ㄱ~ㅎ(겹자음 포함) · 모음 ㅏ~ㅣ (U+3131~U+3163) +const int _jamoConsonantFirst = 0x3131; +const int _jamoConsonantLast = 0x314E; +const int _jamoVowelFirst = 0x314F; +const int _jamoVowelLast = 0x3163; + +/// 자음 ㄹ(리을) — 자음 가운데 방향 조사가 "로"인 것 +const int _jamoRieul = 0x3139; + +/// 숫자 0~9 (ASCII) +const int _digitZero = 0x30; +const int _digitNine = 0x39; + +/// 끝소리 — 조사를 고르는 데 쓴다 +enum _Coda { + /// 받침 없음 (프로젝트 · ㅏ · 2) + none, + + /// ㄹ 받침 — 방향 조사가 "로"다 (메일 · ㄹ · 1) + rieul, + + /// 그 밖의 받침 (휴지통 · ㄱ · 0) + other, +} + +/// 숫자 0~9 의 끝소리 — 읽는 소리 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 +const List<_Coda> _digitCodas = <_Coda>[ + _Coda.other, // 영 + _Coda.rieul, // 일 + _Coda.none, // 이 + _Coda.other, // 삼 + _Coda.none, // 사 + _Coda.none, // 오 + _Coda.other, // 육 + _Coda.rieul, // 칠 + _Coda.rieul, // 팔 + _Coda.none, // 구 +]; + +/// 마지막 글자의 끝소리 — 앞뒤 공백은 떼고 본다(따옴표 · 괄호는 떼지 않는다). 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글) null +/// - 한글 음절: 그 받침 +/// - 한글 호환 자모: 자음은 받침 있음(이름 기역 · 니은 · 리을 … 이 모두 받침으로 끝난다 — ㄹ 은 [_Coda.rieul]), 모음은 받침 없음 +/// - 숫자: 읽는 소리로 ([_digitCodas]) +/// (웹 `korean.ts` 와 같은 규칙 — 2026-11-07 자모 · 숫자를 함께 넓혔다) +_Coda? _lastCoda(String word) { final String trimmed = word.trim(); if (trimmed.isEmpty) return null; final int last = trimmed.runes.last; - if (!_isHangulSyllable(last)) return null; - return (last - _hangulFirst) % _jongseongCount; + if (_isHangulSyllable(last)) { + final int jongseong = (last - _hangulFirst) % _jongseongCount; + if (jongseong == _noJongseong) return _Coda.none; + return jongseong == _jongseongRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoConsonantFirst && last <= _jamoConsonantLast) { + return last == _jamoRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoVowelFirst && last <= _jamoVowelLast) return _Coda.none; + if (last >= _digitZero && last <= _digitNine) { + return _digitCodas[last - _digitZero]; + } + return null; } -/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가). -/// 한글로 끝나지 않는 이름(영문 · 숫자)은 소리를 알 수 없어 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). -String subjectParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '이(가)'; - return jongseong == _noJongseong ? '가' : '이'; -} +/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가 · ‘ㄱㅁ’이 · 2가). +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호)면 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). +String subjectParticle(String word) => switch (_lastCoda(word)) { + null => '이(가)', + _Coda.none => '가', + _Coda.rieul || _Coda.other => '이', +}; -/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로). -/// 한글로 끝나지 않는 이름은 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). -String directionParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '(으)로'; - return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로'; -} +/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로 · 프로젝트1로). +/// 소리를 알 수 없는 끝 글자면 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). +String directionParticle(String word) => switch (_lastCoda(word)) { + null => '(으)로', + _Coda.none || _Coda.rieul => '로', + _Coda.other => '으로', +}; /// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는). -/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다 +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호 — 확장자로 끝나는 파일 이름)면 "은(는)" 이다 /// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙) -String topicParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '은(는)'; - return jongseong == _noJongseong ? '는' : '은'; -} +String topicParticle(String word) => switch (_lastCoda(word)) { + null => '은(는)', + _Coda.none => '는', + _Coda.rieul || _Coda.other => '은', +}; /// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 67b0262..f0b798b 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -87,7 +87,7 @@ abstract final class MailComposeTexts { '$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.'; /// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획). - /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)" + /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 라틴 글자 확장자로 끝나는 이름은 소리를 몰라 "은(는)"(숫자로 끝나면 그 숫자의 소리로) static String overTotal(String total, String name, {int others = 0}) { final String target = others > 0 ? '‘$name’ 외 ${formatCount(others)}개' diff --git a/flutter/test/features/mail/compose_attachments_test.dart b/flutter/test/features/mail/compose_attachments_test.dart index 77098d2..8f5d13e 100644 --- a/flutter/test/features/mail/compose_attachments_test.dart +++ b/flutter/test/features/mail/compose_attachments_test.dart @@ -250,7 +250,7 @@ void main() { ); }); - test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () { + test('은/는 — 받침에 맞추고, 소리를 모르는 끝 글자(라틴 글자 · 기호)면 "은(는)"', () { expect(topicParticle('사진'), '은'); expect(topicParticle('보고서'), '는'); expect(topicParticle('외 2개'), '는'); diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart index 9963852..d5ca0be 100644 --- a/flutter/test/features/mail/mail_rules_test.dart +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -108,6 +108,49 @@ void main() { expect(directionParticle('프로젝트'), '로'); expect(directionParticle('Archive'), '(으)로'); }); + + test('한글 자모 · 숫자는 읽는 소리로 — 자음은 받침 있음(ㄹ 은 "로"), 모음은 받침 없음', () { + // 끝 글자 → 이/가 · 으로/로 · 은/는 + for (final (String last, String subject, String direction, String topic) + in <(String, String, String, String)>[ + ('ㄱ', '이', '으로', '은'), // 기역 + ('ㄹ', '이', '로', '은'), // 리을 + ('ㅁ', '이', '으로', '은'), // 미음 + ('ㄳ', '이', '으로', '은'), // 기역시옷 — 겹자음도 받침 있음 + ('ㅏ', '가', '로', '는'), // 아 + ('0', '이', '으로', '은'), // 영 + ('1', '이', '로', '은'), // 일 + ('2', '가', '로', '는'), // 이 + ('3', '이', '으로', '은'), // 삼 + ('4', '가', '로', '는'), // 사 + ('5', '가', '로', '는'), // 오 + ('6', '이', '으로', '은'), // 육 + ('7', '이', '로', '은'), // 칠 + ('8', '이', '로', '은'), // 팔 + ('9', '가', '로', '는'), // 구 + ]) { + expect(subjectParticle(last), subject, reason: last); + expect(directionParticle(last), direction, reason: last); + expect(topicParticle(last), topic, reason: last); + } + // 낱말의 끝 글자로 본다 — 초성 검색어 · 번호로 끝나는 이름 + expect(directionParticle('ㄱㅁ'), '으로'); + expect(subjectParticle('ㄱㅁ'), '이'); + expect(directionParticle('프로젝트1'), '로'); + expect(subjectParticle('2026'), '이'); + // 라틴 글자 · 기호는 소리를 몰라 지금처럼 + expect(subjectParticle('ARA'), '이(가)'); + expect(directionParticle('100%'), '(으)로'); + expect(topicParticle('v'), '은(는)'); + }); + + test('끝의 공백은 떼고 보고, 따옴표 · 괄호는 떼지 않는다 — 그 글자로 끝나면 소리를 몰라 "이(가)" 꼴', () { + expect(subjectParticle('계약 '), '이'); + expect(directionParticle('ㄹ '), '로'); + expect(subjectParticle('계약)'), '이(가)'); + expect(subjectParticle('"계약"'), '이(가)'); + expect(directionParticle('(2)'), '(으)로'); + }); }); group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () { -- 2.52.0 From bec78bce420b9acb3c01cffe07d91f7d9357f15d Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 17:00:36 +0900 Subject: [PATCH 22/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?2=EC=B0=A8=20=EB=B3=B4=EB=93=9C(App1SearchConditions)=EB=A5=BC?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=9B=90=EB=B3=B8=EC=97=90=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자가 확정한 조건으로 찾기 화면 넷이다(캔버스 v63) — 조건 시트, 날짜 고르기, 조건이 걸린 결과, 조건에 맞는 메일이 없을 때. 계획과 다르게 정한 것: [조건]은 위 막대 맨 오른쪽에 고정, 시트에서 낱말 8개를 넘기면 시트 안에 알림, 날짜는 오늘까지만 고르고 칸마다 지우기 단추, 가림막은 앱 대화상자 색. 화면의 글은 화면 상수가 기준이다. Co-Authored-By: Claude Opus 5.5 --- .../webmail/App1SearchConditions.dc.html | 599 ++++++++++++++++++ docs/design/webmail/canvas.json | 8 +- 2 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 docs/design/webmail/App1SearchConditions.dc.html diff --git a/docs/design/webmail/App1SearchConditions.dc.html b/docs/design/webmail/App1SearchConditions.dc.html new file mode 100644 index 0000000..c57617f --- /dev/null +++ b/docs/design/webmail/App1SearchConditions.dc.html @@ -0,0 +1,599 @@ + + + + +앱 · 메일 검색 · 조건으로 찾기 + + + + + + + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+

{{ b14Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + + +
+ +
+

조건으로 찾기

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ 시작 날짜 + +
+
+ 끝 날짜 + +
+
+
+
+
+ 첨부 + +
+

검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.

+
+
+
+ +

검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.

+
+
+ + +
+
+
+
+
+ +
+

{{ b15Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + + +
+ +
+

조건으로 찾기

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ 시작 날짜 +
+ + +
+
+
+ 끝 날짜 +
+ + +
+
+
+

끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.

+
+
+
+ 첨부 + +
+

검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.

+
+
+
+
+ + +
+
+
+ + +
+
+ 시작 날짜 + +
+ 11월 7일 (토) + +
+
+ + +
+ + +
+
+
+
+
+ +
+

{{ b16Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+ +
+

조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음

+ +
+
+

{{ count16 }}

+ + +
+
+ +
+

{{ b17Title }}

+
+
+ + + + + + + + + +
+ +
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+
+ +
+
+ 찾을 곳 +
+ + +
+
+
+
+
+

{{ cond17 }}

+ +
+ +

0통

+
+ +

조건에 맞는 메일이 없습니다

+

조건을 바꾸거나 줄여 보세요.

+
+
+ +
+ +

찾을 말이 너무 많습니다

+

검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.

+
+
+ +
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index a639d08..683c2b4 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -56,9 +56,10 @@ "App1Compose.dc.html": { "x": 4716, "y": 32000, "w": 1784, "h": 956, "title": "앱 · 쓰기", "is_interactive": true }, "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true }, "App1Search.dc.html": { "x": 0, "y": 33450, "w": 3098, "h": 1028, "title": "앱 · 검색 (찾기 · 결과)", "is_interactive": true }, - "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true } + "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true }, + "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -74,7 +75,8 @@ "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, "phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 }, "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 }, - "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 } + "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 }, + "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 } }, "designSystems": [] } -- 2.52.0 From facf9cd9361ee932f688a119e66cb4687a0b0f36 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 19:00:15 +0900 Subject: [PATCH 23/32] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=202=EC=B0=A8=20=E2=80=94=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EC=B0=BE=EA=B8=B0=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EA=B2=B0=EA=B3=BC=EC=9D=98=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을 바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고, 결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 · 기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다. [조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고, 시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다. 바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다. 리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져 초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 · 단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을 고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다. 시험 872 → 932. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/theme/app_spacing.dart | 14 + flutter/lib/core/theme/app_theme.dart | 11 + .../data/repositories/mail_repository.dart | 8 +- .../mail/domain/search_conditions.dart | 314 ++++ .../mail/presentation/mail_search_view.dart | 84 +- .../mail/presentation/mail_texts.dart | 126 +- .../presentation/pages/mail_search_page.dart | 60 +- .../providers/mail_providers.dart | 11 + .../providers/mail_search_controller.dart | 110 +- .../providers/mail_search_state.dart | 11 +- .../presentation/widgets/mail_search_bar.dart | 147 +- .../widgets/mail_search_conditions_sheet.dart | 1229 +++++++++++++++ .../widgets/mail_search_states.dart | 17 +- flutter/test/core/theme/app_theme_test.dart | 15 + .../mail/mail_search_conditions_test.dart | 1331 +++++++++++++++++ .../mail/mail_search_controller_test.dart | 165 +- .../features/mail/mail_search_page_test.dart | 2 +- .../features/mail/mail_search_view_test.dart | 77 + .../features/mail/search_conditions_test.dart | 456 ++++++ flutter/test/support/mail_fakes.dart | 64 +- 20 files changed, 4170 insertions(+), 82 deletions(-) create mode 100644 flutter/lib/features/mail/domain/search_conditions.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart create mode 100644 flutter/test/features/mail/mail_search_conditions_test.dart create mode 100644 flutter/test/features/mail/search_conditions_test.dart diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index fe7c354..f093048 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -195,4 +195,18 @@ abstract final class AppSize { /// 결과 머리(개수 줄)의 높이 static const double searchCountLineHeight = 36; + + // ── 앱 보드에서 정한 크기 (`App1SearchConditions` — 2026-11-07 확정) ── + /// 위 막대 [조건]의 보이는 원(시트가 열려 있거나 조건이 걸려 있을 때 옅은 청록) — 누르는 곳은 [touchMin] + static const double searchConditionsToggle = 40; + + /// 아래에서 올라오는 시트 위에 남기는 자리 — 시트를 끝까지 키워도 검색 칸이 보인다(보드의 시트 위 96) + static const double sheetTopGap = 96; + + /// 시트 손잡이(보이기만 한다 — 끌기는 시트 전체가 받는다) + static const double sheetHandleWidth = 32; + static const double sheetHandleHeight = 4; + + /// 시트의 [찾기]처럼 채운 주 단추의 최소 폭 + static const double primaryActionMinWidth = 120; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 2f4f40b..0f4e81f 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -98,6 +98,17 @@ abstract final class AppTheme { ), ), navigationBarTheme: _navigationBarTheme(scheme, textTheme), + // 아래에서 올라오는 시트(옮기기 · 조건으로 찾기)의 가림막 — 대화상자 가림막 토큰(`--color-backdrop`). Flutter 기본은 검정 54% + bottomSheetTheme: const BottomSheetThemeData( + modalBarrierColor: AppColors.backdrop, + ), + // 날짜 고르는 창 — 머리의 고른 날짜("11월 7일 (토)")는 22 · 600(보드 `App1SearchConditions`). Flutter 기본(headlineLarge 32)은 크다. + // 창의 가림막은 테마에 자리가 없어(대화상자 테마는 확인 창까지 바꾼다) 부르는 쪽이 넘긴다 + datePickerTheme: DatePickerThemeData( + headerHeadlineStyle: textTheme.headlineSmall?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), extensions: const [AppExtraColors.light], ); } diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index 6c875c0..ceb7da7 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -12,6 +12,7 @@ import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:dio/dio.dart'; /// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다 @@ -103,11 +104,13 @@ class MailRepository { } /// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. - /// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어가 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다. + /// [q] 가 비면 싣지 않는다 — 조건([conditions] — `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`, 빈 칸은 빼고)만으로 찾는다. + /// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어 · 조건이 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다. /// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다. /// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 Future> searchMessages({ required String q, + SearchConditions conditions = SearchConditions.none, String? mailboxId, String? cursor, int limit = kMailPageSize, @@ -117,7 +120,8 @@ class MailRepository { final Response response = await _dio.get( MailApiPaths.messages, queryParameters: { - 'q': q, + if (q.isNotEmpty) 'q': q, + ...searchConditionsParams(conditions), 'mailboxId': ?mailboxId, 'limit': limit, 'cursor': ?cursor, diff --git a/flutter/lib/features/mail/domain/search_conditions.dart b/flutter/lib/features/mail/domain/search_conditions.dart new file mode 100644 index 0000000..ccd9af0 --- /dev/null +++ b/flutter/lib/features/mail/domain/search_conditions.dart @@ -0,0 +1,314 @@ +/// 메일 검색의 조건(2차 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부) — 순수 함수 (계약 2절 "검색 — 14단계"). +/// +/// **짝: 웹 `frontend/src/shared/utils/mail-search.ts`(조건 다듬기 · API 쿼리 · 읽기 화면의 강조 쿼리 · 합친 낱말 수 · 한 글자 판정 · 기간 고르기) +/// · `mail-vacation.ts` 의 `startOfDayIso` · `endOfDayIso`.** 같은 규칙을 그대로 옮겼다 — 한쪽을 고치면 둘 다. +/// 조건 줄의 글(“조건: 이도윤 님이 보낸 메일, …”)은 화면 글이라 `presentation/mail_search_view.dart` 에 있다. +/// +/// 웹과 다른 점: 조건은 화면 상태에만 둔다(주소에 싣지 않는다 — 앱의 검색어와 같다). 날짜는 글(YYYY-MM-DD)이 아니라 [SearchDay] 다. +library; + +import 'package:arasystem_app/core/utils/js_case.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:flutter/foundation.dart'; + +/// 기기 시간대의 날짜 하나(시각 없음) — 조건의 시작 날짜 · 끝 날짜. 두 날짜 모두 든다(계약 — `since` · `until` 은 양 끝을 포함한다) +@immutable +class SearchDay implements Comparable { + const SearchDay(this.year, this.month, this.day); + + /// [date] 의 날짜 — 기기 시간대로 본다(UTC 시각이면 기기 시간대로 바꾼 날짜) + factory SearchDay.of(DateTime date) { + final DateTime local = date.toLocal(); + return SearchDay(local.year, local.month, local.day); + } + + final int year; + final int month; + final int day; + + /// 있는 날짜인가 — 2월 30일처럼 넘어간 날짜는 아니다 + bool get isValid { + final DateTime check = DateTime.utc(year, month, day); + return check.year == year && check.month == month && check.day == day; + } + + /// 날짜 고르는 창에 넘기는 값 — 기기 시간대의 그날(시각 0시) + DateTime toLocalDate() => DateTime(year, month, day); + + bool isAfter(SearchDay other) => compareTo(other) > 0; + + @override + int compareTo(SearchDay other) { + if (year != other.year) return year.compareTo(other.year); + if (month != other.month) return month.compareTo(other.month); + return day.compareTo(other.day); + } + + @override + bool operator ==(Object other) => + other is SearchDay && + other.year == year && + other.month == month && + other.day == day; + + @override + int get hashCode => Object.hash(year, month, day); + + /// YYYY-MM-DD (웹 `` 의 값과 같은 모양 — 시험 · 진단용) + @override + String toString() => + '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; +} + +/// 고를 수 있는 날짜의 처음 — 날짜 고르는 창의 `firstDate` (웹 `SEARCH_DAY_MIN`) +const SearchDay kSearchDayMin = SearchDay(1970, 1, 1); + +/// 고를 수 있는 날짜의 끝 — 9999-12-31 은 UTC 의 다음 날 0시가 10000년이라 서버가 받지 않는다(웹 `SEARCH_DAY_MAX`). +/// 앱의 날짜 고르는 창은 오늘까지만 보인다 +const SearchDay kSearchDayMax = SearchDay(9999, 12, 30); + +/// 찾을 수 있는 날짜인가 — 있는 날짜이고 [kSearchDayMin] ~ [kSearchDayMax] 안 (웹 `isSearchDay`) +bool isSearchDay(SearchDay day) => + day.isValid && + day.compareTo(kSearchDayMin) >= 0 && + day.compareTo(kSearchDayMax) <= 0; + +/// 검색 칸 밖의 조건 — 글 칸은 비면 없는 것, 날짜는 기기 시간대의 날짜(두 날짜 모두 든다) +@immutable +class SearchConditions { + const SearchConditions({ + this.from = '', + this.to = '', + this.subject = '', + this.body = '', + this.since, + this.until, + this.hasAttachment = false, + }); + + /// 조건 없음 + static const SearchConditions none = SearchConditions(); + + /// 보낸 사람(이름 · 주소) + final String from; + + /// 받는 사람과 참조 + final String to; + final String subject; + + /// 본문 + final String body; + + /// 시작 날짜 — 그날 0시부터. 없으면 null + final SearchDay? since; + + /// 끝 날짜 — 그날이 끝날 때까지(다음 날 0시 앞). 없으면 null + final SearchDay? until; + + /// 첨부 파일이 있는 메일만 + final bool hasAttachment; + + /// 조건이 하나도 없다 (웹 `hasSearchConditions` 의 반대) + bool get isEmpty => + from.isEmpty && + to.isEmpty && + subject.isEmpty && + body.isEmpty && + since == null && + until == null && + !hasAttachment; + + bool get isNotEmpty => !isEmpty; + + @override + bool operator ==(Object other) => + other is SearchConditions && + other.from == from && + other.to == to && + other.subject == subject && + other.body == body && + other.since == since && + other.until == until && + other.hasAttachment == hasAttachment; + + @override + int get hashCode => + Object.hash(from, to, subject, body, since, until, hasAttachment); +} + +/// 조건 다듬기 — 글 칸은 검색어처럼(앞뒤 공백 · UTF-16 200자 — [normalizeSearchText]), 날짜는 찾을 수 있는 날짜만([isSearchDay]). +/// 시작이 끝보다 늦은 기간은 통째로 버린다 — 아무것도 맞지 않는 검색이 되지 않게(시트는 넘기기 전에 칸 아래에 알린다). +/// 낱말 수는 자르지 않는다 — 넘으면 찾지 않고 까닭을 보인다([exceedsCombinedSearchWords]) (웹 `normalizeSearchConditions`) +SearchConditions normalizeSearchConditions(SearchConditions? input) { + if (input == null) return SearchConditions.none; + final SearchDay? since = _dayOrNull(input.since); + final SearchDay? until = _dayOrNull(input.until); + final bool reversed = isReversedPeriod(since, until); + return SearchConditions( + from: normalizeSearchText(input.from), + to: normalizeSearchText(input.to), + subject: normalizeSearchText(input.subject), + body: normalizeSearchText(input.body), + since: reversed ? null : since, + until: reversed ? null : until, + hasAttachment: input.hasAttachment, + ); +} + +SearchDay? _dayOrNull(SearchDay? day) => + day != null && isSearchDay(day) ? day : null; + +/// 날짜(연 · 월 · 일 — 넘친 일은 다음 달로 넘긴다) → 기기 시간대의 그날 0시. 시험은 시간대를 고정한 것을 넣는다 +typedef SearchMidnight = DateTime Function(int year, int month, int day); + +DateTime _deviceMidnight(int year, int month, int day) => + DateTime(year, month, day); + +/// API 쿼리의 조건(`GET /api/messages` — 빈 칸은 싣지 않는다). +/// 날짜는 기기 시간대의 시작 날짜 0시(`after`) · 끝 날짜 다음 날 0시(`before`)를 UTC ISO 로 — 웹 `searchConditionsParams` 와 같다 +/// (부재중과 같은 방식). 첨부는 `hasAttachment=true` 만(계약 — 다른 값은 400 `VAL_001`) +Map searchConditionsParams( + SearchConditions conditions, { + SearchMidnight midnight = _deviceMidnight, +}) { + final SearchDay? since = conditions.since; + final SearchDay? until = conditions.until; + return { + if (conditions.from.isNotEmpty) 'from': conditions.from, + if (conditions.to.isNotEmpty) 'to': conditions.to, + if (conditions.subject.isNotEmpty) 'subject': conditions.subject, + if (conditions.body.isNotEmpty) 'body': conditions.body, + if (since != null) + 'after': _utcIso(midnight(since.year, since.month, since.day)), + if (until != null) + 'before': _utcIso(midnight(until.year, until.month, until.day + 1)), + if (conditions.hasAttachment) 'hasAttachment': kSearchHasAttachmentValue, + }; +} + +/// API 의 `hasAttachment` 값 — 이것만 받는다(웹 `SEARCH_HAS_ATTACHMENT_VALUE`) +const String kSearchHasAttachmentValue = 'true'; + +/// "2026-10-05T15:00:00.000Z" — JS `toISOString()` 과 같은 모양 +String _utcIso(DateTime instant) => instant.toUtc().toIso8601String(); + +/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 칸 넷만(기간 · 첨부는 서버가 받지 않는다). 빈 칸은 빼고, 하나도 없으면 null (웹 `messageHighlightQueryOf`) +MessageHighlightQuery? messageHighlightQueryOf( + String q, + SearchConditions conditions, +) { + final MessageHighlightQuery query = MessageHighlightQuery( + q: q, + from: conditions.from, + to: conditions.to, + subject: conditions.subject, + body: conditions.body, + ); + return query.isEmpty ? null : query; +} + +/// 검색어와 조건 칸 넷을 합친 낱말 수 — 서버 한도(8개)와 같은 기준(띄어쓰기로 나눈 조각을 칸마다 세어 더한다) +int combinedSearchWords(String q, SearchConditions conditions) => + countSearchWords(q) + + countSearchWords(conditions.from) + + countSearchWords(conditions.to) + + countSearchWords(conditions.subject) + + countSearchWords(conditions.body); + +/// 합친 낱말이 한도([kMailSearchMaxWords])를 넘는가 — 넘으면 묻지 않는다 +bool exceedsCombinedSearchWords(String q, SearchConditions conditions) => + combinedSearchWords(q, conditions) > kMailSearchMaxWords; + +/// 한 글자 낱말뿐이라 본문을 찾지 않은 검색인가 (색인 방식 — 서버와 같은 판정, [isSingleCharSearch]). +/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만 그렇다 — 보낸 사람 · 받는 사람 · 제목 조건만이면 본문은 원래 찾지 않는다 (웹 `skipsBodySearch`) +bool skipsBodySearch(String q, SearchConditions conditions) { + if (parseSearchTerms(q).isEmpty && + parseSearchTerms(conditions.body).isEmpty) { + return false; + } + return isSingleCharSearch([ + q, + conditions.from, + conditions.to, + conditions.subject, + conditions.body, + ]); +} + +// ── 기간 고르기 (웹 SEARCH_PERIOD_* · periodRange · periodOf) ───────────── + +/// 기간 고르기 — 고르기 칸의 차례 그대로. 최근 N주 · 달 · 해는 그만큼 앞의 같은 날부터 오늘까지(두 날짜 모두 든다) +enum SearchPeriod { + all, + week(days: 7), + month(months: 1), + quarter(months: 3), + year(months: 12), + custom; + + const SearchPeriod({this.days = 0, this.months = 0}); + + /// 오늘에서 뺄 날 · 달 수 (미리 정한 기간만) + final int days; + final int months; + + /// 미리 정한 기간인가 — 전체 기간 · 날짜 고르기는 아니다 + bool get isPreset => this != all && this != custom; +} + +/// 오늘에서 그만큼 앞의 같은 날 — 달은 그 달의 마지막 날을 넘지 않는다(3월 31일의 1달 앞은 2월 28 · 29일). +/// 날짜 셈은 UTC 로 한다(일광 절약 시간의 0시가 없는 날에도 날짜가 바뀌지 않게) +SearchDay _shiftBack(SearchDay today, SearchPeriod preset) { + final int month = today.month - preset.months; + final int lastDay = DateTime.utc(today.year, month + 1, 0).day; + final int day = (today.day < lastDay ? today.day : lastDay) - preset.days; + final DateTime shifted = DateTime.utc(today.year, month, day); + return SearchDay(shifted.year, shifted.month, shifted.day); +} + +/// 미리 정한 기간의 두 날짜 — 그만큼 앞의 같은 날부터 오늘까지. 전체 기간 · 날짜 고르기는 날짜가 없다 (웹 `periodRange`) +({SearchDay? since, SearchDay? until}) periodRange( + SearchPeriod period, + DateTime today, +) { + if (!period.isPreset) return (since: null, until: null); + final SearchDay day = SearchDay.of(today); + return (since: _shiftBack(day, period), until: day); +} + +/// 두 날짜가 어떤 기간 고르기인가 — 둘 다 없으면 전체 기간, 오늘 기준의 미리 정한 기간과 같으면 그것, 아니면 날짜 고르기 (웹 `periodOf`) +SearchPeriod periodOf(SearchDay? since, SearchDay? until, DateTime today) { + if (since == null && until == null) return SearchPeriod.all; + for (final SearchPeriod period in SearchPeriod.values) { + if (!period.isPreset) continue; + final ({SearchDay? since, SearchDay? until}) range = periodRange( + period, + today, + ); + if (range.since == since && range.until == until) return period; + } + return SearchPeriod.custom; +} + +/// 시작 날짜가 끝 날짜보다 늦은가 — 그러면 아무것도 맞지 않는다(찾기 전에 막는다). 한쪽만 있으면 아니다 +bool isReversedPeriod(SearchDay? since, SearchDay? until) => + since != null && until != null && since.isAfter(until); + +// ── 조건 줄의 "나" ───────────────────────────────────── + +/// 조건 줄의 "나" — 보낸 사람 · 받는 사람 칸의 값이 이 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (웹 `SearchConditionsMe`) +typedef SearchConditionsMe = ({String name, String email}); + +/// 견줄 모양 — 앞뒤 공백을 빼고 NFC(한글 자모만 모은다 — [composeHangul]) · 소문자(JS 와 같게 — [jsLowerCase]). +/// 영문 이름 · 주소는 대소문자를 가리지 않는다 +String _comparable(String value) => + jsLowerCase(composeHangul(trimSearchSpaces(value))); + +/// 칸의 값이 나인가 — 내 이름이나 내 주소와 통째로 같을 때만(일부만 같으면 아니다) +bool isSearchConditionMe(String value, SearchConditionsMe? me) { + if (me == null) return false; + final String text = _comparable(value); + if (text.isEmpty) return false; + return text == _comparable(me.email) || text == _comparable(me.name); +} diff --git a/flutter/lib/features/mail/presentation/mail_search_view.dart b/flutter/lib/features/mail/presentation/mail_search_view.dart index 1d95cbf..5515b75 100644 --- a/flutter/lib/features/mail/presentation/mail_search_view.dart +++ b/flutter/lib/features/mail/presentation/mail_search_view.dart @@ -1,14 +1,17 @@ -/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · 웹 `MailSearchHeader` · `MailSearchNotices` · `useMailListRows`). +/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` · 웹 `MailSearchHeader` · `MailSearchNotices` · +/// `MailSearchConditionLine` · `useMailListRows`). /// /// - 결과 머리: "‘계약’ · 4통" · 찾는 중 "‘계약’ · 찾는 중" · 전체 수를 모르면(`total` null) "‘계약’"(통 수를 보이지 않는다 — 계약). -/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다. +/// 조건만으로 찾았으면 "2통" · "찾는 중"(전체 수를 모르면 머리가 없다). +/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 본문을 찾을 말이 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다. +/// - 조건 줄: "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음"(웹 `searchConditionsSummary` — 쉼표로 잇는다). /// - 결과 줄: 한 메일함에서 찾았으면 그 메일함의 규칙(보낸편지함 · 임시보관함), 전체 메일함이면 줄마다 그 메일의 메일함(시각 앞에 이름). library; import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; -import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; @@ -23,7 +26,7 @@ enum MailSearchNotice { singleChar, } -/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)면 null +/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)거나 보일 글이 없으면 null String? searchCountLineOf(MailSearchState state) { final String? query = state.query; if (query == null || !state.showsHead) return null; @@ -31,19 +34,22 @@ String? searchCountLineOf(MailSearchState state) { return MailSearchTexts.searching(query); } final int? total = state.total; - return total == null - ? MailSearchTexts.quoted(query) - : MailSearchTexts.count(query, total); + if (total != null) return MailSearchTexts.count(query, total); + final String quoted = MailSearchTexts.quoted(query); + return quoted.isEmpty ? null : quoted; } -/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다 +/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다. +/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다([skipsBodySearch] — 웹과 같은 판정) MailSearchNotice? searchNoticeOf(MailSearchState state) { final String? query = state.query; if (query == null || state.phase != MailSearchPhase.ready) return null; return switch (state.indexStatus) { SearchIndexStatus.building => MailSearchNotice.indexing, SearchIndexStatus.ready => - isSingleCharSearch([query]) ? MailSearchNotice.singleChar : null, + skipsBodySearch(query, state.conditions) + ? MailSearchNotice.singleChar + : null, null => null, }; } @@ -85,13 +91,69 @@ MailRowView searchRowViewOf( } /// 결과에서 연 메일에 넘기는 값 — 강조 쿼리는 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만 싣는다 -/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 찾은 메일함은 한 메일함에서 찾았을 때만 +/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 검색어와 글 조건 넷만 — 기간 · 첨부는 서버가 받지 않는다 +/// ([messageHighlightQueryOf] — 실을 칸이 없으면 null). 찾은 메일함은 한 메일함에서 찾았을 때만 MailSearchOpen searchOpenOf(MailSearchItem item, MailSearchState state) { final String? query = state.query; return MailSearchOpen( highlight: item.highlightSupported && query != null - ? MessageHighlightQuery(q: query) + ? messageHighlightQueryOf(query, state.conditions) : null, scopeMailboxId: state.searchedAll ? null : state.searchedMailboxId, ); } + +// ── 조건 줄 (웹 `searchConditionsSummary`) ───────────────────────────── + +/// 날짜 칸 · 조건 줄의 날짜 — "2026. 10. 1.", [withYear] 가 아니면 "10. 1." +String formatSearchDay(SearchDay day, {bool withYear = true}) => withYear + ? '${day.year}. ${day.month}. ${day.day}.' + : '${day.month}. ${day.day}.'; + +/// 조건 줄의 기간 — 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(같은 해면 끝 날짜의 해를 뺀다 · 같은 날이면 "… 하루"). 없으면 null +String? _periodText(SearchConditions conditions, DateTime today) { + final SearchDay? since = conditions.since; + final SearchDay? until = conditions.until; + final SearchPeriod period = periodOf(since, until, today); + if (period == SearchPeriod.all) return null; + if (period != SearchPeriod.custom) return MailSearchTexts.period(period); + if (since != null && until != null) { + if (since == until) { + return MailSearchTexts.lineOneDay(formatSearchDay(since)); + } + return MailSearchTexts.lineRange( + formatSearchDay(since), + formatSearchDay(until, withYear: since.year != until.year), + ); + } + if (since != null) return MailSearchTexts.lineSince(formatSearchDay(since)); + return until == null + ? null + : MailSearchTexts.lineUntil(formatSearchDay(until)); +} + +/// 결과 머리 위의 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다). +/// 보낸 사람 · 받는 사람이 나([me] — 내 이름이나 주소와 통째로 같음)면 "내가 보낸 메일" · "내가 받은 메일". 조건이 없으면 빈 글 +String searchConditionsSummary( + SearchConditions conditions, + DateTime today, { + SearchConditionsMe? me, +}) { + if (conditions.isEmpty) return ''; + final List parts = [ + if (conditions.from.isNotEmpty) + isSearchConditionMe(conditions.from, me) + ? MailSearchTexts.lineFromMe + : MailSearchTexts.lineFrom(conditions.from), + if (conditions.to.isNotEmpty) + isSearchConditionMe(conditions.to, me) + ? MailSearchTexts.lineToMe + : MailSearchTexts.lineTo(conditions.to), + if (conditions.subject.isNotEmpty) + MailSearchTexts.lineSubject(conditions.subject), + if (conditions.body.isNotEmpty) MailSearchTexts.lineBody(conditions.body), + ?_periodText(conditions, today), + if (conditions.hasAttachment) MailSearchTexts.lineAttachment, + ]; + return '${MailSearchTexts.linePrefix}${parts.join(MailSearchTexts.lineSeparator)}'; +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index f0b798b..2ac2c16 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -10,6 +10,7 @@ import 'package:arasystem_app/core/utils/korean_format.dart'; import 'package:arasystem_app/core/utils/korean_text.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; /// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은 /// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다 @@ -390,8 +391,10 @@ abstract final class MailListTexts { static const String compose = '메일 쓰기'; } -/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). -/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT`)를 그대로 옮겼다 — +/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내, +/// `App1SearchConditions` — 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간). +/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT` · +/// `SEARCH_CONDITIONS_TEXT` · `SEARCH_PERIOD_LABELS` · `SEARCH_CONDITION_LINE_TEXT`)를 그대로 옮겼다 — /// 앱에서 새로 쓴 글은 그 줄에 적었다. 목록 줄 · 받지 못함의 글은 [MailListTexts] 를 함께 쓴다 abstract final class MailSearchTexts { // ── 위 막대 ── @@ -412,15 +415,17 @@ abstract final class MailSearchTexts { static const String scopeAllHint = '휴지통과 스팸메일함은 빼고 찾습니다'; // ── 결과 머리 ── - /// 전체 수를 모를 때(`total` null) — "‘계약’" - static String quoted(String query) => '‘$query’'; + /// 전체 수를 모를 때(`total` null) — "‘계약’" (조건만으로 찾았으면 빈 글 — 머리를 보이지 않는다) + static String quoted(String query) => query.isEmpty ? '' : '‘$query’'; - /// "‘계약’ · 4통" - static String count(String query, int count) => - '‘$query’ · ${formatCount(count)}통'; + /// "‘계약’ · 4통" · 조건만으로 찾았으면 "2통" + static String count(String query, int count) => query.isEmpty + ? '${formatCount(count)}통' + : '‘$query’ · ${formatCount(count)}통'; - /// "‘계약’ · 찾는 중" - static String searching(String query) => '‘$query’ · 찾는 중'; + /// "‘계약’ · 찾는 중" · 조건만으로 찾으면 "찾는 중" + static String searching(String query) => + query.isEmpty ? '찾는 중' : '‘$query’ · 찾는 중'; /// 결과 목록 — 화면 낭독기 이름 (웹 `title`) static const String results = '검색 결과'; @@ -438,12 +443,19 @@ abstract final class MailSearchTexts { '한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.'; // ── 결과 없음 ── - /// "‘해지 통보’가 들어간 메일이 없습니다" - static String emptyTitle(String query) => - '‘$query’${subjectParticle(query)} 들어간 메일이 없습니다'; + /// "‘해지 통보’가 들어간 메일이 없습니다" · 조건만으로 찾았으면 "조건에 맞는 메일이 없습니다" + static String emptyTitle(String query) => query.isEmpty + ? '조건에 맞는 메일이 없습니다' + : '‘$query’${subjectParticle(query)} 들어간 메일이 없습니다'; - /// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차) - static const String emptyHint = '다른 말로 찾아 보세요.'; + /// 검색어와 조건으로 찾았을 때의 도움말 (웹 `emptyHint`) + static const String emptyHint = '다른 말로 찾거나 조건을 줄여 보세요.'; + + /// 조건만으로 찾았을 때의 도움말 (웹 `emptyConditionsHint`) + static const String emptyConditionsHint = '조건을 바꾸거나 줄여 보세요.'; + + /// 검색어만으로 찾았을 때의 도움말 — 걸린 조건이 없으니 조건을 줄이라고 하지 않는다 (웹 `emptyWordsHint`) + static const String emptyWordsHint = '다른 말로 찾아 보세요.'; /// 색인을 준비하는 동안에만 더하는 한계 안내 (웹 `wordLimitHint`) static const String wordLimitHint = @@ -464,9 +476,9 @@ abstract final class MailSearchTexts { // ── 찾을 말이 너무 많음 ── static const String tooManyWordsTitle = '찾을 말이 너무 많습니다'; - /// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 …" + /// 검색어와 조건의 낱말을 합쳐 셌다 — 결과 자리 · 시트의 [찾기] 위 (웹 `tooManyWordsHint`) static String tooManyWordsHint(int max) => - '검색어에는 낱말을 ${formatCount(max)}개까지 넣을 수 있습니다. 낱말을 몇 개 줄여 주세요.'; + '검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${formatCount(max)}개까지입니다. 낱말을 몇 개 줄여 주세요.'; // ── 찾기 전 ── /// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀) @@ -479,6 +491,88 @@ abstract final class MailSearchTexts { /// 검색 결과에서 연 메일의 머리 — "메일함 받은편지함"의 이름 칸 (웹 `mailboxLine`) static const String mailboxLine = '메일함'; + + // ── 조건으로 찾기 — 위 막대의 [조건] · 시트 (2차 — 웹 `SEARCH_CONDITIONS_TEXT`) ── + /// [조건] — 툴팁 · 화면 낭독기 이름 · 시트 제목 (웹 `toggleTitle` · `title`) + static const String conditions = '조건으로 찾기'; + + /// 조건이 걸려 있을 때 [조건]의 낭독에 더한다 (웹 `toggleApplied`) + static const String conditionsApplied = '조건이 걸려 있음'; + + /// [조건]의 화면 낭독기 이름 — "조건으로 찾기, 조건이 걸려 있음" + static String conditionsLabel({required bool applied}) => + applied ? '$conditions, $conditionsApplied' : conditions; + + /// 시트의 [✕] — 화면 낭독기 (앱에서 새로 씀) + static const String closeConditions = '닫기'; + + static const String fromLabel = '보낸 사람'; + static const String toLabel = '받는 사람'; + static const String subjectLabel = '제목'; + + /// 칸 이름 "내용" — 조건 줄도 "내용에 ‘…’이 든 메일"이다 + static const String bodyLabel = '내용'; + static const String periodLabel = '기간'; + static const String attachmentLabel = '첨부'; + + /// 보낸 사람 · 받는 사람 칸의 흐린 글 + static const String personPlaceholder = '이름이나 메일 주소'; + static const String subjectPlaceholder = '제목에 든 말'; + static const String bodyPlaceholder = '내용에 든 말'; + + /// 기간 고르기의 이름 (웹 `SEARCH_PERIOD_LABELS`) + static String period(SearchPeriod period) => switch (period) { + SearchPeriod.all => '전체 기간', + SearchPeriod.week => '최근 1주', + SearchPeriod.month => '최근 1달', + SearchPeriod.quarter => '최근 3달', + SearchPeriod.year => '최근 1년', + SearchPeriod.custom => '날짜 고르기', + }; + + /// 날짜 칸의 이름 · 날짜 고르는 창의 머리 + static const String since = '시작 날짜'; + static const String until = '끝 날짜'; + + /// 날짜 칸의 [✕] — 화면 낭독기 (앱에서 새로 씀) + static const String clearSince = '시작 날짜 지우기'; + static const String clearUntil = '끝 날짜 지우기'; + + static const String attachmentOnly = '첨부 파일이 있는 메일만'; + static const String conditionsHint = + '검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.'; + + /// 날짜 고르기의 시작이 끝보다 늦을 때 — 두 날짜 칸 아래 (웹 `periodOrder`) + static const String periodOrder = '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.'; + + /// 시트의 [조건 지우기](칸만 비운다) · 조건 줄의 [조건 지우기](걸린 조건을 뺀다) + static const String clearConditions = '조건 지우기'; + + /// 시트의 [찾기] + static const String applyConditions = '찾기'; + + // ── 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (웹 `SEARCH_CONDITION_LINE_TEXT` — 쉼표로 잇는다) ── + static const String linePrefix = '조건: '; + static const String lineSeparator = ', '; + static String lineFrom(String who) => '$who 님이 보낸 메일'; + static String lineTo(String who) => '$who 님이 받은 메일'; + + /// 보낸 사람 · 받는 사람 칸의 값이 내 이름이나 내 주소일 때 + static const String lineFromMe = '내가 보낸 메일'; + static const String lineToMe = '내가 받은 메일'; + + /// "제목에 ‘견적’이 든 메일" — 조사(이 · 가)는 말의 받침으로 + static String lineSubject(String text) => + '제목에 ‘$text’${subjectParticle(text)} 든 메일'; + static String lineBody(String text) => + '내용에 ‘$text’${subjectParticle(text)} 든 메일'; + static String lineSince(String day) => '$day부터'; + static String lineUntil(String day) => '$day까지'; + static String lineRange(String since, String until) => '$since부터 $until까지'; + + /// 시작 날짜와 끝 날짜가 같은 날 — "2026. 11. 6.부터 11. 6.까지" 대신 + static String lineOneDay(String day) => '$day 하루'; + static const String lineAttachment = '첨부 있음'; } /// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」) diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart index 6ce8ff1..3b19957 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -6,6 +6,7 @@ import 'package:arasystem_app/core/theme/app_spacing.dart'; import 'package:arasystem_app/core/theme/app_typography.dart'; import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:arasystem_app/features/mail/domain/search_text.dart'; import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; @@ -18,18 +19,22 @@ import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_pro import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` (앱 디자인 README 「검색」 — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). +/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` (앱 디자인 README 「검색」 — +/// 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내, 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간 · "나"). /// /// - **탭 안 화면**이다 — 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫힌다. -/// 검색어는 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`). -/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다. -/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다. [←] · 뒤로 가기가 검색을 닫는다. +/// 검색어 · 조건은 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`). +/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이고 걸린 조건도 없으면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다. +/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다(걸린 조건은 남는다). [←] · 뒤로 가기가 검색을 닫는다. +/// - [조건](맨 오른쪽)은 「조건으로 찾기」 시트를 연다 — 연 동안 · 조건이 걸려 있으면 옅은 청록 원. 시트의 [찾기]는 조건을 걸고 칸의 지금 글로 찾는다. +/// 조건이 걸리면 찾을 곳 줄 아래에 조건 줄("조건: …" + [조건 지우기])이 보인다. /// - 찾을 곳은 찾기 전부터 보이고, 바꾸면 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다 — 다시 찾으면 자판을 내린다. /// - 결과 줄은 목록 줄 그대로(고르지 않는다 — 사진은 단추가 아니고 길게 눌러도 아무 일 없다). 누르면 읽기 화면(임시보관함은 쓰기 화면), /// 14단계 서버의 결과면 강조 쿼리를 함께 넘긴다. 읽기 화면에서 지운 뒤 [되돌리기] 알림이 이 화면 아래에 뜬다([메일 쓰기]는 없다). @@ -56,6 +61,12 @@ class _MailSearchPageState extends ConsumerState { final TextEditingController _field = TextEditingController(); final FocusNode _focus = FocusNode(); + /// [조건]의 초점 — 시트를 열기 전에 받아 둔다(시트가 닫히면 이 단추로 돌아와 자판이 다시 올라오지 않는다) + final FocusNode _conditionsFocus = FocusNode(); + + /// 「조건으로 찾기」 시트가 열려 있다 + bool _conditionsOpen = false; + MailSearchController get _controller => ref.read(mailSearchProvider(_args).notifier); @@ -63,16 +74,40 @@ class _MailSearchPageState extends ConsumerState { void dispose() { _field.dispose(); _focus.dispose(); + _conditionsFocus.dispose(); super.dispose(); } // ── 하는 일 ── - /// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다 + /// 자판의 [검색] — 빈 칸 · 공백뿐이고 걸린 조건도 없으면 자판을 그대로 둔다 void _submit(String text) { if (_controller.submit(text)) _focus.unfocus(); } + /// [조건] — 시트를 연다. [찾기]가 넘긴 조건을 걸고 칸의 지금 글로 찾는다(닫기만 했으면 아무 일 없다) + Future _openConditions() async { + if (_conditionsOpen) return; + _conditionsFocus.requestFocus(); + setState(() => _conditionsOpen = true); + final SearchConditions? picked = await showSearchConditionsSheet( + context, + applied: ref.read(mailSearchProvider(_args)).conditions, + searchText: _field.text, + clock: ref.read(clockProvider), + ); + if (!mounted) return; + setState(() => _conditionsOpen = false); + if (picked != null && _controller.submit(_field.text, conditions: picked)) { + _focus.unfocus(); + } + } + + /// 조건 줄의 [조건 지우기] — 걸린 조건을 빼고 칸의 글(비었으면 보이는 결과의 검색어)로 다시 찾는다 + void _clearConditions() { + if (_controller.clearConditions(fieldText: _field.text)) _focus.unfocus(); + } + /// [✕] — 칸과 결과를 비우고 칸에 초점(자판이 다시 올라온다) void _clear() { _field.clear(); @@ -124,6 +159,7 @@ class _MailSearchPageState extends ConsumerState { }); final String? countLine = searchCountLineOf(search); final MailSearchNotice? notice = searchNoticeOf(search); + final bool conditionsApplied = search.conditions.isNotEmpty; return Scaffold( appBar: MailSearchBar( controller: _field, @@ -131,6 +167,10 @@ class _MailSearchPageState extends ConsumerState { onSubmitted: _submit, onClear: _clear, onClose: _close, + onConditions: () => unawaited(_openConditions()), + conditionsFocusNode: _conditionsFocus, + conditionsApplied: conditionsApplied, + conditionsOpen: _conditionsOpen, ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -140,6 +180,15 @@ class _MailSearchPageState extends ConsumerState { allMailboxes: search.allMailboxes, onChanged: _setScope, ), + if (conditionsApplied) + MailSearchConditionLine( + text: searchConditionsSummary( + search.conditions, + ref.read(clockProvider)(), + me: ref.watch(mailSelfProvider), + ), + onClear: _clearConditions, + ), if (countLine != null) MailSearchCountLine(countLine), if (notice != null) MailSearchNoticeLine(_noticeText(notice)), Expanded( @@ -248,6 +297,7 @@ class _SearchBody extends StatelessWidget { content = _FillViewport( child: MailSearchEmpty( query: query, + hasConditions: search.conditions.isNotEmpty, indexing: search.indexStatus == SearchIndexStatus.building, onSearchAll: search.searchedAll ? null : onSearchAll, ), diff --git a/flutter/lib/features/mail/presentation/providers/mail_providers.dart b/flutter/lib/features/mail/presentation/providers/mail_providers.dart index d37d490..bc49b2f 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_providers.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_providers.dart @@ -2,6 +2,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다 @@ -20,3 +21,13 @@ final Provider mailAccountProvider = Provider((Ref ref) { ? session.info.email : null; }); + +/// 메일을 보는 직원의 이름 · 주소 — 검색 조건 줄이 "내가 보낸 메일"을 알아본다(웹은 `authStore.user`). +/// [mailAccountProvider] 와 같은 때만 있고 그 밖에는 null +final Provider mailSelfProvider = + Provider((Ref ref) { + final SessionState? session = ref.watch(sessionProvider).value; + return session is SignedIn && session.info.restriction == null + ? (name: session.info.name, email: session.info.email) + : null; + }); diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart index 3d48e89..a4ef563 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart @@ -11,6 +11,7 @@ import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:arasystem_app/features/mail/domain/search_text.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; @@ -36,9 +37,12 @@ mailSearchProvider = NotifierProvider.autoDispose MailSearchController.new, ); -/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙. +/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(조건 · 이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` · `useMailSearchNavigation` 의 검색과 같은 규칙. /// -/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다. +/// - 자판의 [검색] · 시트의 [찾기]로만 찾는다(치는 동안 묻지 않는다). 검색어와 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다. +/// 같은 검색어 · 같은 조건 · 같은 곳이면 보이는 채로 다시 찾는다. +/// - **조건**(2차)은 화면 상태에만 둔다([MailSearchState.conditions] — 주소에 싣지 않는다). 걸린 조건은 [검색] · 찾을 곳 바꾸기 · 다시 찾기 · +/// 이어 찾기가 모두 그대로 쓴다. 칸이 비어도 조건이 있으면 조건만으로 찾는다(웹과 같다). [✕]는 검색어와 결과만 지우고 조건은 남긴다. /// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다. /// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다. /// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다. @@ -130,72 +134,94 @@ class MailSearchController extends Notifier { // ── 하는 일 ───────────────────────────────────────── - /// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다). - /// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다 - bool submit(String text) { + /// 자판의 [검색] · 시트의 [찾기]([conditions] — 그 조건을 걸고 찾는다. 주지 않으면 걸려 있는 조건 그대로 — 웹 `startSearch` 와 같다). + /// - 검색어도 조건도 없으면: [검색]은 아무 일도 하지 않고 거짓(자판도 그대로 둔다), 시트의 [찾기]는 조건을 빼고 찾기 전으로(요청 없음 — + /// 웹은 검색을 닫지만 앱은 검색 화면에 머문다) + /// - 칸이 비어도 조건이 있으면 조건만으로 찾는다 + /// - 검색어와 조건의 낱말이 합쳐 한도를 넘으면 묻지 않는다(조건은 걸린 채 — 조건 줄과 [조건 지우기]가 남는다) + /// - 같은 검색어 · 같은 조건 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다 + bool submit(String text, {SearchConditions? conditions}) { if (!ref.mounted) return false; final String q = normalizeSearchText(text); - if (q.isEmpty) return false; - if (countSearchWords(q) > kMailSearchMaxWords) { + final SearchConditions next = conditions == null + ? state.conditions + : normalizeSearchConditions(conditions); + if (q.isEmpty && next.isEmpty) { + if (conditions == null) return false; + _reset(SearchConditions.none); + return true; + } + if (exceedsCombinedSearchWords(q, next)) { _beginGeneration(); _removed.clear(); state = MailSearchState( phase: MailSearchPhase.tooManyWords, allMailboxes: state.allMailboxes, indexStatus: state.indexStatus, + conditions: next, ); return true; } final MailSearchState current = state; - if (current.phase == MailSearchPhase.ready && + final bool same = + current.phase == MailSearchPhase.ready && current.query == q && - current.searchedAll == current.allMailboxes) { - unawaited(_search(q, keepVisible: true)); - } else { - unawaited(_search(q)); - } + current.searchedAll == current.allMailboxes && + current.conditions == next; + unawaited(_search(q, conditions: next, keepVisible: same)); return true; } - /// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 둘 다 없으면 곳만 바꿔 둔다. - /// 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다) + /// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어, 조건만으로 찾았으면 그 조건)로 바로 다시 찾는다. + /// 보이는 결과도 없으면 곳만 바꿔 둔다. 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다) bool setScope({required bool all, required String fieldText}) { if (!ref.mounted || state.allMailboxes == all) return false; state = state.copyWith(allMailboxes: all); final String typed = normalizeSearchText(fieldText); - final String? q = typed.isNotEmpty ? typed : state.query; + if (typed.isNotEmpty) return submit(typed); + final String? q = state.query; return q != null && submit(q); } - /// [전체 메일함에서 찾기] — 보이는 결과의 검색어를 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐) + /// [전체 메일함에서 찾기] — 보이는 결과의 검색어 · 조건을 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐) void searchAll() { if (!ref.mounted) return; final String? q = state.query; state = state.copyWith(allMailboxes: true); - if (q != null) unawaited(_search(q)); + if (q != null) unawaited(_search(q, conditions: state.conditions)); } - /// [✕] — 결과를 비우고 찾기 전으로(찾을 곳은 그대로) + /// [✕] — 검색어의 결과를 비우고 찾기 전으로(찾을 곳 · 걸린 조건은 그대로 — 조건 줄이 남는다) void clear() { if (!ref.mounted) return; - _beginGeneration(); - _removed.clear(); - state = MailSearchState( - allMailboxes: state.allMailboxes, - indexStatus: state.indexStatus, - ); + _reset(state.conditions); } - /// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry]) + /// 조건 줄의 [조건 지우기] — 걸린 조건을 모두 뺀다. 찾은 검색이면 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 다시 찾고, + /// 그 글도 없으면 요청 없이 찾기 전으로(웹은 검색을 닫는다). 찾기 전([✕] 뒤)이면 조건만 뺀다. 다시 찾았으면 참(화면이 자판을 내린다) + bool clearConditions({required String fieldText}) { + if (!ref.mounted) return false; + final MailSearchState current = state; + if (current.conditions.isEmpty) return false; + if (current.phase == MailSearchPhase.idle) { + state = current.copyWith(conditions: SearchConditions.none); + return false; + } + final String typed = normalizeSearchText(fieldText); + final String q = typed.isNotEmpty ? typed : current.query ?? ''; + return submit(q, conditions: SearchConditions.none); + } + + /// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry]). 걸린 조건 그대로 Future refresh() async { if (!ref.mounted) return; final String? q = state.query; if (q == null) return; switch (state.phase) { case MailSearchPhase.ready: - await _search(q, keepVisible: true); + await _search(q, conditions: state.conditions, keepVisible: true); case MailSearchPhase.failed: - await _search(q); + await _search(q, conditions: state.conditions); case MailSearchPhase.idle: case MailSearchPhase.tooManyWords: case MailSearchPhase.searching: @@ -203,11 +229,22 @@ class MailSearchController extends Notifier { } } - /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다 + /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다(걸린 조건 그대로) Future retry() async { if (!ref.mounted) return; final String? q = state.query; - if (q != null) await _search(q); + if (q != null) await _search(q, conditions: state.conditions); + } + + /// 찾기 전으로 — 도는 요청을 끊고 결과를 비운다(찾을 곳 · 준비 상태는 그대로, 조건은 [conditions]) + void _reset(SearchConditions conditions) { + _beginGeneration(); + _removed.clear(); + state = MailSearchState( + allMailboxes: state.allMailboxes, + indexStatus: state.indexStatus, + conditions: conditions, + ); } /// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만) @@ -257,9 +294,13 @@ class MailSearchController extends Notifier { // ── 찾기 ────────────────────────────────────────── - /// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), + /// 첫 쪽을 묻고 이어 찾는다([q] 가 비면 [conditions] 만으로) — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), /// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다) - Future _search(String q, {bool keepVisible = false}) async { + Future _search( + String q, { + required SearchConditions conditions, + bool keepVisible = false, + }) async { final Ref ref = this.ref; final int seq = _beginGeneration(); final CancelToken cancel = _cancel!; @@ -270,6 +311,7 @@ class MailSearchController extends Notifier { refreshing: true, fill: MailSearchFill.none, loadMoreFailed: false, + conditions: conditions, ) : MailSearchState( phase: MailSearchPhase.searching, @@ -277,6 +319,7 @@ class MailSearchController extends Notifier { query: q, searchedAll: all, indexStatus: state.indexStatus, + conditions: conditions, ); unawaited(_refreshIndexStatus()); String? mailboxId; @@ -298,6 +341,7 @@ class MailSearchController extends Notifier { try { final MailPage page = await _repository.searchMessages( q: q, + conditions: conditions, mailboxId: mailboxId, silent: !keepVisible, cancelToken: cancel, @@ -316,6 +360,7 @@ class MailSearchController extends Notifier { items: List.unmodifiable(items), pageInfo: page.pageInfo, indexStatus: state.indexStatus, + conditions: conditions, ); await _fillRound( seq, @@ -365,6 +410,7 @@ class MailSearchController extends Notifier { try { page = await _repository.searchMessages( q: current.query!, + conditions: current.conditions, mailboxId: current.searchedMailboxId, cursor: cursor, silent: beforeAny, diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart index d9e249a..1e36745 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:flutter/foundation.dart'; /// 검색 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 메일함에서 검색을 다시 열어도 앞 화면의 결과와 섞이지 않게 — @@ -68,6 +69,7 @@ class MailSearchState { this.partial = false, this.loadMoreFailed = false, this.indexStatus, + this.conditions = SearchConditions.none, }); final MailSearchPhase phase; @@ -75,9 +77,14 @@ class MailSearchState { /// 찾을 곳 — 전체 메일함(참) · 시작한 메일함(거짓). 다음 검색이 쓴다 final bool allMailboxes; - /// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null. 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다 + /// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null, 조건만으로 찾았으면 빈 글. + /// 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다 final String? query; + /// 걸린 조건(2차 — 시트의 [찾기]로 건다) — 보이는 결과를 찾은 조건이고, 다음 검색 · 다시 찾기 · 이어 찾기가 그대로 쓴다. + /// 찾기 전([✕] 뒤) · 낱말이 너무 많음에도 남는다(조건 줄이 보인다). 화면 상태에만 둔다(주소에 싣지 않는다) + final SearchConditions conditions; + /// 보이는 결과를 전체 메일함에서 찾았다 — 줄마다 메일함이 다르다 final bool searchedAll; @@ -135,6 +142,7 @@ class MailSearchState { bool? partial, bool? loadMoreFailed, Object? indexStatus = _unset, + SearchConditions? conditions, }) => MailSearchState( phase: phase ?? this.phase, allMailboxes: allMailboxes ?? this.allMailboxes, @@ -157,5 +165,6 @@ class MailSearchState { indexStatus: identical(indexStatus, _unset) ? this.indexStatus : indexStatus as SearchIndexStatus?, + conditions: conditions ?? this.conditions, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart index 6d15ec7..3682ce8 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart @@ -8,7 +8,8 @@ import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) (보드 `App1Search`, 높이 64). +/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) · 맨 오른쪽 [조건](조건으로 찾기) +/// (보드 `App1Search` · `App1SearchConditions`, 높이 64). /// /// 칸은 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 글 자판(메일 주소 자판은 한글을 막는다) · 자동 고침 끔 · 추천 켬 · /// 둥근 따옴표 끔(큰따옴표가 둥근 따옴표가 되면 구절이 아니라 낱말로 찾는다). UTF-16 200자까지(서버와 같은 단위). @@ -21,6 +22,10 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { required this.onSubmitted, required this.onClear, required this.onClose, + required this.onConditions, + required this.conditionsFocusNode, + this.conditionsApplied = false, + this.conditionsOpen = false, super.key, }); @@ -30,6 +35,18 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { final VoidCallback onClear; final VoidCallback onClose; + /// [조건] — 조건으로 찾기 시트를 연다 + final VoidCallback onConditions; + + /// [조건]의 초점 — 시트를 열기 전에 받아 두어, 시트가 닫히면 초점이 칸이 아니라 이 단추로 돌아온다(자판이 다시 올라오지 않게) + final FocusNode conditionsFocusNode; + + /// 조건이 걸려 있다 — [조건]이 옅은 청록 원 · 낭독에 "조건이 걸려 있음" + final bool conditionsApplied; + + /// 시트가 열려 있다 — [조건]이 옅은 청록 원 · 펼침 상태 + final bool conditionsOpen; + @override Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); @@ -73,7 +90,7 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { smartDashesType: SmartDashesType.disabled, smartQuotesType: SmartQuotesType.disabled, inputFormatters: const [ - _LineBreaksToSpaces(), + LineBreaksToSpacesFormatter(), Utf16LengthLimitFormatter(kMailSearchMaxLength), ], style: style, @@ -107,14 +124,134 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { onPressed: onClear, ), ), + _ConditionsButton( + applied: conditionsApplied, + open: conditionsOpen, + focusNode: conditionsFocusNode, + onPressed: onConditions, + ), ], ); } } -/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결) -class _LineBreaksToSpaces extends TextInputFormatter { - const _LineBreaksToSpaces(); +/// [조건] — 누르는 곳 48 안의 보이는 원 40(시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원 · 진한 청록 아이콘, 평소는 바탕 없음). +/// 화면 낭독기는 "조건으로 찾기"(걸려 있으면 ", 조건이 걸려 있음")를 펼침 상태(시트가 열려 있음)와 함께 읽는다 — 툴팁은 늘 "조건으로 찾기" +class _ConditionsButton extends StatelessWidget { + const _ConditionsButton({ + required this.applied, + required this.open, + required this.focusNode, + required this.onPressed, + }); + + final bool applied; + final bool open; + final FocusNode focusNode; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final bool active = applied || open; + return Semantics( + container: true, + button: true, + expanded: open, + label: MailSearchTexts.conditionsLabel(applied: applied), + onTap: onPressed, + child: ExcludeSemantics( + child: IconButton( + key: const ValueKey('mail-search-conditions'), + focusNode: focusNode, + icon: const Icon(Icons.tune, size: AppSize.iconLarge), + tooltip: MailSearchTexts.conditions, + onPressed: onPressed, + style: IconButton.styleFrom( + minimumSize: const Size.square(AppSize.searchConditionsToggle), + fixedSize: const Size.square(AppSize.searchConditionsToggle), + padding: EdgeInsets.zero, + backgroundColor: active ? scheme.primaryContainer : null, + foregroundColor: active + ? scheme.onPrimaryContainer + : theme.extension()!.textStrong, + ), + ), + ), + ); + } +} + +/// 걸린 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + 오른쪽 [조건 지우기] (보드 `App1SearchConditions` — 최소 48 · 아래 선). +/// 글이 길면 낱말 단위로 줄을 바꾼다(13). [조건 지우기]는 걸린 조건을 모두 뺀다(부르는 쪽이 다시 찾는다) +class MailSearchConditionLine extends StatelessWidget { + const MailSearchConditionLine({ + required this.text, + required this.onClear, + super.key, + }); + + final String text; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppSize.touchMin), + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s16, + right: AppSpacing.s8, + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.s6), + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.s8), + TextButton( + onPressed: onClear, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(MailSearchTexts.clearConditions), + ), + ], + ), + ), + ), + ); + } +} + +/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결). +/// 검색 칸 · 조건 시트의 글 칸이 쓴다 — 그래서 칸은 한 줄(`maxLines: 1`)이 아니다(한 줄 칸은 Flutter 가 줄바꿈을 먼저 지운다) +class LineBreaksToSpacesFormatter extends TextInputFormatter { + const LineBreaksToSpacesFormatter(); static final RegExp _lineBreaks = RegExp(r'\r\n|\r|\n'); diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart new file mode 100644 index 0000000..7949d00 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart @@ -0,0 +1,1229 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/core/utils/utf16_length.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +/// 「조건으로 찾기」 시트를 연다 — 아래에서 올라와 탭 막대까지 덮는다(보드 `App1SearchConditions` 14 · 15). +/// +/// [applied](걸린 조건)로 칸을 채운 사본을 고친다. [찾기]가 넘긴 조건(다듬은 것)을 돌려주고, +/// [찾기] 없이 닫으면([✕] · 뒤로 · 가림막 · 끌어 내림) null — 고친 값은 버린다(다시 열면 걸린 조건으로 다시 채운다). +/// [searchText] 는 검색 칸의 지금 글 — 낱말 수를 조건과 합쳐 센다. [clock] 으로 오늘을 센다(최근 N · 날짜 고르는 창) +Future showSearchConditionsSheet( + BuildContext context, { + required SearchConditions applied, + required String searchText, + required Clock clock, +}) { + final ThemeData theme = Theme.of(context); + return showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: theme.colorScheme.surface, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (BuildContext sheetContext) => MailSearchConditionsSheet( + applied: applied, + searchText: searchText, + clock: clock, + ), + ); +} + +/// 시트의 글 칸 넷 (보드의 차례) +enum _TextCondition { from, to, subject, body } + +/// 날짜 칸 둘 +enum _DayEdge { since, until } + +/// 「조건으로 찾기」 시트 (보드 `App1SearchConditions` · 웹 `MailSearchConditions` · `MailSearchConditionFields` · `MailSearchPeriodField` · +/// `useSearchConditionsDraft`). +/// +/// - 머리(제목 · [✕])와 아래 단추 줄([조건 지우기] · [찾기])은 고정이고 가운데만 스크롤한다. 가운데가 스크롤되면 머리 아래에 선. +/// 자판이 올라오면 시트도 함께 올라간다. 자리가 모자라면(가운데가 칸 하나보다 낮아짐 — 가로 화면에 자판 · 큰 글자 · 낱말 알림) +/// 위 96 을 남기지 않고 시트 전체를 한 스크롤로 둔다(머리 · 단추 줄도 함께 움직인다 — [_SheetBody]). +/// - 글 칸 넷의 자판은 검색 칸과 같은 결(글 자판 · 자동 고침 끔 · 둥근 따옴표 · 대시 끔 · 줄바꿈은 띄어쓰기 · UTF-16 200자), +/// 칸의 [검색] 키는 [찾기]와 같다. +/// - 기간: 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다. 열 때는 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 이름으로 보인다. +/// 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다. 두 날짜는 서로 막지 않고, 시작이 끝보다 늦으면 +/// 끝 날짜 칸을 빨갛게 하고 두 칸 아래에 알린다(고른 즉시). +/// - [조건 지우기]: 칸만 비운다(기간 · 날짜 · 첨부 · 칸 아래 알림까지) — 시트는 열린 채, 찾지 않고, 초점은 그 단추에(웹 2026-11-07). +/// - [찾기]: 날짜가 거꾸로면 넘기지 않고 끝 날짜 칸으로 스크롤 · 초점. 검색 칸의 글과 조건의 낱말이 합쳐 8개를 넘으면 시트를 연 채 +/// 단추 줄 위에 알린다(낱말을 고치면 지운다). 통과하면 다듬은 조건을 돌려주고 닫는다 +class MailSearchConditionsSheet extends StatefulWidget { + const MailSearchConditionsSheet({ + required this.applied, + required this.searchText, + required this.clock, + super.key, + }); + + /// 걸린 조건 — 열 때 칸을 채운다 + final SearchConditions applied; + + /// 검색 칸의 지금 글 — 낱말 수를 합쳐 센다 + final String searchText; + final Clock clock; + + @override + State createState() => + _MailSearchConditionsSheetState(); +} + +class _MailSearchConditionsSheetState extends State { + final Map<_TextCondition, TextEditingController> _texts = + <_TextCondition, TextEditingController>{ + for (final _TextCondition field in _TextCondition.values) + field: TextEditingController(), + }; + final ScrollController _scroll = ScrollController(); + final FocusNode _sinceFocus = FocusNode(); + final FocusNode _untilFocus = FocusNode(); + final FocusNode _clearFocus = FocusNode(); + final GlobalKey _untilKey = GlobalKey(); + + /// 단추 줄 — 낱말 알림을 띄운 뒤 보이게 한다 + final GlobalKey _footerKey = GlobalKey(); + + SearchPeriod _period = SearchPeriod.all; + + /// 바로 전에 고른 기간 — 날짜 고르기로 바꿀 때 그 날짜로 빈 칸을 채운다 + SearchPeriod _previousPeriod = SearchPeriod.all; + SearchDay? _since; + SearchDay? _until; + bool _hasAttachment = false; + + /// [찾기]가 낱말이 너무 많아 넘기지 못했다 — 단추 줄 위의 알림(낱말을 고치면 지운다) + bool _tooManyWords = false; + + /// 가운데가 스크롤됐다 — 머리 아래 선 + bool _scrolled = false; + + @override + void initState() { + super.initState(); + _fill(widget.applied); + _scroll.addListener(_onScroll); + } + + @override + void dispose() { + for (final TextEditingController controller in _texts.values) { + controller.dispose(); + } + _scroll.dispose(); + _sinceFocus.dispose(); + _untilFocus.dispose(); + _clearFocus.dispose(); + super.dispose(); + } + + DateTime _today() => widget.clock(); + + /// 걸린 조건으로 칸을 채운다 (열 때 · [조건 지우기]는 빈 조건으로) + void _fill(SearchConditions source) { + final SearchConditions value = normalizeSearchConditions(source); + _texts[_TextCondition.from]!.text = value.from; + _texts[_TextCondition.to]!.text = value.to; + _texts[_TextCondition.subject]!.text = value.subject; + _texts[_TextCondition.body]!.text = value.body; + _period = periodOf(value.since, value.until, _today()); + _previousPeriod = _period; + _since = value.since; + _until = value.until; + _hasAttachment = value.hasAttachment; + _tooManyWords = false; + } + + void _onScroll() { + final bool scrolled = _scroll.hasClients && _scroll.offset > 0; + if (scrolled != _scrolled) setState(() => _scrolled = scrolled); + } + + /// 날짜 고르기의 시작이 끝보다 늦다 — 끝 날짜 칸이 빨갛고 두 칸 아래에 알린다 + bool get _reversed => + _period == SearchPeriod.custom && isReversedPeriod(_since, _until); + + // ── 하는 일 ── + + /// 글 칸을 고쳤다 — 낱말 알림을 지운다 + void _onWordsEdited() { + if (_tooManyWords) setState(() => _tooManyWords = false); + } + + void _changePeriod(SearchPeriod period) { + setState(() { + _period = period; + if (period == SearchPeriod.custom && _since == null && _until == null) { + final ({SearchDay? since, SearchDay? until}) range = periodRange( + _previousPeriod, + _today(), + ); + _since = range.since; + _until = range.until; + } + _previousPeriod = period; + }); + } + + /// 날짜 고르는 창 — 1970-01-01 ~ 오늘, 처음은 칸의 날짜(없으면 오늘 · 범위 안으로). 가림막은 대화상자 가림막 색 + Future _pickDay(_DayEdge edge) async { + // 창이 닫히면 초점이 그 날짜 칸으로 돌아온다(전에 쓰던 글 칸으로 돌아가 자판이 다시 올라오지 않게) + (edge == _DayEdge.since ? _sinceFocus : _untilFocus).requestFocus(); + final DateTime now = _today().toLocal(); + final DateTime first = kSearchDayMin.toLocalDate(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime last = today.isBefore(first) ? first : today; + final SearchDay? current = edge == _DayEdge.since ? _since : _until; + DateTime initial = current?.toLocalDate() ?? today; + if (initial.isBefore(first)) initial = first; + if (initial.isAfter(last)) initial = last; + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: first, + lastDate: last, + currentDate: today, + helpText: edge == _DayEdge.since + ? MailSearchTexts.since + : MailSearchTexts.until, + barrierColor: Theme.of(context).colorScheme.scrim, + ); + if (!mounted || picked == null) return; + setState(() { + if (edge == _DayEdge.since) { + _since = SearchDay.of(picked); + } else { + _until = SearchDay.of(picked); + } + }); + } + + /// 날짜 칸의 [✕] — 그 날짜를 지우고 초점은 그 칸으로(누른 단추가 사라진다) + void _clearDay(_DayEdge edge) { + setState(() { + if (edge == _DayEdge.since) { + _since = null; + } else { + _until = null; + } + }); + (edge == _DayEdge.since ? _sinceFocus : _untilFocus).requestFocus(); + } + + /// [조건 지우기] — 칸만 비운다(시트는 열린 채 · 찾지 않음 · 초점은 이 단추에) + void _clear() { + setState(() => _fill(SearchConditions.none)); + _clearFocus.requestFocus(); + } + + /// [찾기] · 글 칸의 [검색] 키 + void _apply() { + if (_reversed) { + // 끝 날짜 칸 아래의 알림은 이미 보인다 — 그 칸으로 + final BuildContext? target = _untilKey.currentContext; + if (target != null) unawaited(Scrollable.ensureVisible(target)); + _untilFocus.requestFocus(); + return; + } + final ({SearchDay? since, SearchDay? until}) range = + _period == SearchPeriod.custom + ? (since: _since, until: _until) + : periodRange(_period, _today()); + final SearchConditions conditions = normalizeSearchConditions( + SearchConditions( + from: _texts[_TextCondition.from]!.text, + to: _texts[_TextCondition.to]!.text, + subject: _texts[_TextCondition.subject]!.text, + body: _texts[_TextCondition.body]!.text, + since: range.since, + until: range.until, + hasAttachment: _hasAttachment, + ), + ); + if (exceedsCombinedSearchWords( + normalizeSearchText(widget.searchText), + conditions, + )) { + setState(() => _tooManyWords = true); + // 알림은 단추 줄 위에 붙는다 — 시트 전체가 한 스크롤이면 끝에 있어 보이지 않으니 다음 프레임에 단추 줄을 보이게 + WidgetsBinding.instance.addPostFrameCallback((Duration _) => _reveal()); + return; + } + Navigator.of(context).pop(conditions); + } + + // ── 그리기 ── + + /// 볼 곳을 보이게 한다(가장 적게 움직여 — 이미 보이면 그대로) — 낱말 알림이 떠 있으면 그 알림과 단추 줄을, 아니면 시트 안의 초점 칸을. + /// 알림을 띄운 뒤 · 모양이 바뀐 뒤(자판이 내려가 고정 모양으로 돌아오면 가운데가 맨 위로 가 초점 칸이 가려질 수 있다) 프레임이 끝나면 부른다 + void _reveal() { + if (!mounted) return; + if (_tooManyWords) { + _footerKey.currentContext?.findRenderObject()?.showOnScreen(); + return; + } + final BuildContext? focused = FocusManager.instance.primaryFocus?.context; + if (focused == null || + !focused.mounted || + focused.findAncestorStateOfType<_MailSearchConditionsSheetState>() != + this) { + return; + } + final RenderObject? target = focused.findRenderObject(); + if (target is RenderBox && target.hasSize) { + // 칸의 테두리 · 안쪽 여백까지 보이게 조금 넓혀서 + target.showOnScreen( + rect: (Offset.zero & target.size).inflate(AppSpacing.s16), + ); + } + } + + /// 가운데가 스크롤 자리가 바뀌었다(자리가 모자라 펼쳐지면 0 으로) — 머리 아래 선을 맞춘다 + bool _onMiddleMetrics(ScrollMetricsNotification notification) { + if (notification.depth == 0) { + final bool scrolled = + notification.metrics.pixels > notification.metrics.minScrollExtent; + if (scrolled != _scrolled) setState(() => _scrolled = scrolled); + } + return false; + } + + /// 트리 모양은 늘 같다 — 바깥 스크롤 > [_SheetBody](머리 · 가운데 스크롤 · 단추 줄). 자리가 모자라 시트 전체를 한 스크롤로 둘 때도 + /// 모양이 바뀌지 않아 칸(TextField)이 새로 만들어지지 않는다 — 자판이 올라오는 동안 초점 · 입력 연결이 끊기지 않는다(가로 화면 — 2026-11-07 리뷰). + /// 자리 판정은 [_SheetBody] 가 이번 레이아웃에서 잰 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기 포함)로 한다 + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double keyboard = MediaQuery.viewInsetsOf(context).bottom; + final double height = constraints.hasBoundedHeight + ? constraints.maxHeight + : MediaQuery.sizeOf(context).height; + final double available = math.max(0, height - keyboard); + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: available), + // 자리가 넉넉하면 내용이 이 틀보다 낮아 스크롤할 것이 없다 — 그때 머리 · 단추 줄을 끄는 손짓은 시트가 받아 내려 닫는다 + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: _SheetBody( + available: available, + // 넉넉할 때는 위 96 을 남긴다 — 모자라면 먼저 이것부터 없앤다(시트가 화면 위까지) + gap: AppSize.sheetTopGap, + // 가운데에 칸 하나도 남지 않으면 다음 모양으로 — 초점을 받은 칸이 다 보이게 + minMiddle: MediaQuery.textScalerOf( + context, + ).scale(AppSize.fieldHeight), + onLayoutChanged: _reveal, + top: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SheetHandle(), + _SheetHeader(divided: _scrolled), + ], + ), + middle: NotificationListener( + onNotification: _onMiddleMetrics, + child: SingleChildScrollView( + key: const ValueKey('search-conditions-middle'), + controller: _scroll, + child: _fields(), + ), + ), + bottom: _SheetFooter( + key: _footerKey, + tooManyWords: _tooManyWords, + clearFocus: _clearFocus, + onClear: _clear, + onApply: _apply, + ), + ), + ), + ), + ); + }, + ); + } + + /// 가운데 — 글 칸 넷 · 기간 · 첨부 · 안내 (위 8 · 좌우 20 · 아래 32, 칸 묶음 사이 16) + Widget _fields() { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s8, + AppSpacing.s20, + AppSpacing.s32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + for (final _TextCondition field in _TextCondition.values) + _ConditionTextField( + key: ValueKey('search-condition-${field.name}'), + label: _labelOf(field), + placeholder: _placeholderOf(field), + controller: _texts[field]!, + onChanged: _onWordsEdited, + onSubmitted: _apply, + ), + _PeriodField( + period: _period, + onChanged: _changePeriod, + dates: _period == SearchPeriod.custom + ? _PeriodDates( + since: _since, + until: _until, + reversed: _reversed, + sinceFocus: _sinceFocus, + untilFocus: _untilFocus, + untilKey: _untilKey, + onPick: (_DayEdge edge) => unawaited(_pickDay(edge)), + onClear: _clearDay, + ) + : null, + ), + _AttachmentField( + value: _hasAttachment, + onChanged: (bool value) => setState(() => _hasAttachment = value), + ), + KeepAllText( + MailSearchTexts.conditionsHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.extension()!.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + ); + } + + static String _labelOf(_TextCondition field) => switch (field) { + _TextCondition.from => MailSearchTexts.fromLabel, + _TextCondition.to => MailSearchTexts.toLabel, + _TextCondition.subject => MailSearchTexts.subjectLabel, + _TextCondition.body => MailSearchTexts.bodyLabel, + }; + + static String _placeholderOf(_TextCondition field) => switch (field) { + _TextCondition.from || + _TextCondition.to => MailSearchTexts.personPlaceholder, + _TextCondition.subject => MailSearchTexts.subjectPlaceholder, + _TextCondition.body => MailSearchTexts.bodyPlaceholder, + }; +} + +/// 칸 묶음의 이름 — 칸 위 15 굵게 (보드) +TextStyle? _groupLabelStyle(ThemeData theme) => + theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.extension()!.textStrong, + ); + +/// 손잡이 — 보이기만 한다(화면 낭독기는 [✕] "닫기"를 쓴다 — 보드의 손잡이도 낭독에서 뺐다). 끌기는 시트 전체가 받는다 +class _SheetHandle extends StatelessWidget { + const _SheetHandle(); + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + child: SizedBox( + height: AppSize.touchMin, + child: Center( + child: Container( + width: AppSize.sheetHandleWidth, + height: AppSize.sheetHandleHeight, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurfaceVariant, + borderRadius: BorderRadius.circular( + AppSize.sheetHandleHeight / 2, + ), + ), + ), + ), + ), + ); + } +} + +/// 머리 — 제목 "조건으로 찾기"(머리) + [✕](닫기 — 고친 값을 버린다). [divided] 면 아래 선(가운데가 스크롤됨) +class _SheetHeader extends StatelessWidget { + const _SheetHeader({required this.divided}); + + final bool divided; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: divided ? extra.borderSubtle : Colors.transparent, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s20, + right: AppSpacing.s4, + ), + child: Row( + children: [ + Expanded( + child: Semantics( + header: true, + child: Text( + MailSearchTexts.conditions, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: AppSize.iconToolbar), + tooltip: MailSearchTexts.closeConditions, + color: extra.textStrong, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ); + } +} + +/// 아래 단추 줄 — (낱말이 너무 많으면) 빨간 알림 · 왼쪽 [조건 지우기] · 오른쪽 [찾기](최소 폭 120). 위 선은 늘 있다 +class _SheetFooter extends StatelessWidget { + const _SheetFooter({ + required this.tooManyWords, + required this.clearFocus, + required this.onClear, + required this.onApply, + super.key, + }); + + final bool tooManyWords; + final FocusNode clearFocus; + final VoidCallback onClear; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surface, + border: Border( + top: BorderSide( + color: theme.extension()!.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (tooManyWords) + Semantics( + container: true, + liveRegion: true, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s12, + AppSpacing.s20, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Icon( + Icons.error_outline, + size: AppSize.iconTiny, + color: scheme.error, + ), + ), + const SizedBox(width: AppSpacing.s8), + Expanded( + child: KeepAllText( + MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s8, + AppSpacing.s12, + AppSpacing.s20, + AppSpacing.s12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + focusNode: clearFocus, + onPressed: onClear, + style: TextButton.styleFrom( + foregroundColor: scheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(MailSearchTexts.clearConditions), + ), + FilledButton( + onPressed: onApply, + style: FilledButton.styleFrom( + minimumSize: const Size( + AppSize.primaryActionMinWidth, + AppSize.touchMin, + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s24, + ), + ), + child: const Text(MailSearchTexts.applyConditions), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 글 칸 하나 — 이름은 칸 위(낭독은 칸에 붙인다 — 이름을 두 번 읽지 않게), 칸 52 · 두 줄까지. +/// 검색 칸과 같은 자판(글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표 · 대시 끔), 줄바꿈은 띄어쓰기, UTF-16 200자, [검색] 키는 [찾기] +class _ConditionTextField extends StatelessWidget { + const _ConditionTextField({ + required this.label, + required this.placeholder, + required this.controller, + required this.onChanged, + required this.onSubmitted, + super.key, + }); + + final String label; + final String placeholder; + final TextEditingController controller; + final VoidCallback onChanged; + final VoidCallback onSubmitted; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle? style = theme.textTheme.bodyLarge; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + ExcludeSemantics(child: Text(label, style: _groupLabelStyle(theme))), + Semantics( + label: label, + child: TextField( + controller: controller, + minLines: 1, + maxLines: 2, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: true, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + inputFormatters: const [ + LineBreaksToSpacesFormatter(), + Utf16LengthLimitFormatter(kMailSearchMaxLength), + ], + style: style, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style?.copyWith(color: theme.colorScheme.outline), + ), + onChanged: (String _) => onChanged(), + onSubmitted: (String _) => onSubmitted(), + // 기본은 [검색] 뒤 초점을 놓는다 — 넘기지 못하면(낱말이 많음) 칸에 남아 고치게 한다 + onEditingComplete: () {}, + ), + ), + ], + ); + } +} + +/// 날짜 고르기의 두 칸 · 그 아래 알림에 넘기는 값 +class _PeriodDates { + const _PeriodDates({ + required this.since, + required this.until, + required this.reversed, + required this.sinceFocus, + required this.untilFocus, + required this.untilKey, + required this.onPick, + required this.onClear, + }); + + final SearchDay? since; + final SearchDay? until; + + /// 시작이 끝보다 늦다 + final bool reversed; + final FocusNode sinceFocus; + final FocusNode untilFocus; + + /// 끝 날짜 칸 — [찾기]가 그 칸으로 스크롤한다 + final GlobalKey untilKey; + final ValueChanged<_DayEdge> onPick; + final ValueChanged<_DayEdge> onClear; +} + +/// 기간 — 고르기 칸(전체 기간 · 최근 1주 · 1달 · 3달 · 1년 · 날짜 고르기), 날짜 고르기면 그 아래 날짜 칸 둘 나란히 · 거꾸로면 알림 +class _PeriodField extends StatelessWidget { + const _PeriodField({ + required this.period, + required this.onChanged, + required this.dates, + }); + + final SearchPeriod period; + final ValueChanged onChanged; + + /// 날짜 고르기일 때만 + final _PeriodDates? dates; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final _PeriodDates? custom = dates; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + ExcludeSemantics( + child: Text( + MailSearchTexts.periodLabel, + style: _groupLabelStyle(theme), + ), + ), + // 낭독은 "기간" 과 고른 기간을 한 단추로 읽는다 + MergeSemantics( + child: Semantics( + label: MailSearchTexts.periodLabel, + child: DropdownButtonFormField( + key: const ValueKey('search-condition-period'), + initialValue: period, + isExpanded: true, + items: >[ + for (final SearchPeriod option in SearchPeriod.values) + DropdownMenuItem( + value: option, + child: Text( + MailSearchTexts.period(option), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (SearchPeriod? value) { + if (value != null) onChanged(value); + }, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + ), + icon: Icon( + Icons.keyboard_arrow_down, + size: AppSize.iconMedium, + color: scheme.onSurfaceVariant, + ), + dropdownColor: scheme.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + if (custom != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s12, + children: [ + Expanded( + child: _DateField( + key: const ValueKey('search-condition-since'), + label: MailSearchTexts.since, + clearLabel: MailSearchTexts.clearSince, + value: custom.since, + invalid: false, + focusNode: custom.sinceFocus, + onPick: () => custom.onPick(_DayEdge.since), + onClear: () => custom.onClear(_DayEdge.since), + ), + ), + Expanded( + child: KeyedSubtree( + key: custom.untilKey, + child: _DateField( + key: const ValueKey('search-condition-until'), + label: MailSearchTexts.until, + clearLabel: MailSearchTexts.clearUntil, + value: custom.until, + invalid: custom.reversed, + error: custom.reversed ? MailSearchTexts.periodOrder : null, + focusNode: custom.untilFocus, + onPick: () => custom.onPick(_DayEdge.until), + onClear: () => custom.onClear(_DayEdge.until), + ), + ), + ), + ], + ), + if (custom.reversed) + Semantics( + container: true, + liveRegion: true, + child: KeepAllText( + MailSearchTexts.periodOrder, + style: theme.textTheme.bodySmall?.copyWith(color: scheme.error), + ), + ), + ], + ], + ); + } +} + +/// 날짜 칸 하나 — 작은 이름("시작 날짜") 아래 칸 52. 값이 있으면 "2026. 11. 7." + 오른쪽 [✕] 48(그 날짜 지우기), 비었으면 오른쪽 달력 아이콘. +/// 누르면 날짜 고르는 창. [invalid] 면 빨간 테두리(낭독은 "잘못됨" · 칸 아래 알림을 설명으로 붙인다) +class _DateField extends StatelessWidget { + const _DateField({ + required this.label, + required this.clearLabel, + required this.value, + required this.invalid, + required this.focusNode, + required this.onPick, + required this.onClear, + this.error, + super.key, + }); + + final String label; + final String clearLabel; + final SearchDay? value; + final bool invalid; + + /// 칸을 설명하는 알림 — 칸 아래의 "끝 날짜를 …" + final String? error; + final FocusNode focusNode; + final VoidCallback onPick; + final VoidCallback onClear; + + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final SearchDay? day = value; + final String? dayText = day == null ? null : formatSearchDay(day); + const Radius corner = Radius.circular(AppRadius.md); + final BorderSide side = invalid + ? BorderSide(color: scheme.error, width: AppSize.focusRingWidth) + : BorderSide(color: scheme.outline, width: AppSize.borderWidth); + final Widget pick = Semantics( + container: true, + button: true, + label: label, + value: dayText, + hint: error, + validationResult: invalid + ? SemanticsValidationResult.invalid + : SemanticsValidationResult.none, + onTap: onPick, + child: ExcludeSemantics( + child: InkWell( + focusNode: focusNode, + onTap: onPick, + borderRadius: dayText == null + ? const BorderRadius.all(corner) + : const BorderRadius.horizontal(left: corner), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppSize.fieldHeight), + child: dayText == null + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + ), + child: Align( + alignment: Alignment.centerRight, + child: Icon( + Icons.calendar_today_outlined, + size: AppSize.iconMedium, + color: scheme.onSurfaceVariant, + ), + ), + ) + : Padding( + padding: const EdgeInsets.only(left: AppSpacing.s12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + dayText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + fontFeatures: _tabular, + ), + ), + ), + ), + ), + ), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + ExcludeSemantics( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppTypography.bold, + color: scheme.onSurfaceVariant, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.fromBorderSide(side), + borderRadius: const BorderRadius.all(corner), + ), + child: Row( + children: [ + Expanded(child: pick), + if (dayText != null) + IconButton( + icon: const Icon(Icons.close, size: AppSize.iconMedium), + tooltip: clearLabel, + color: scheme.onSurfaceVariant, + onPressed: onClear, + ), + ], + ), + ), + ], + ); + } +} + +/// 첨부 — 묶음 이름 "첨부" 아래 체크 줄 48 "첨부 파일이 있는 메일만"(줄 어디를 눌러도 바뀐다) +class _AttachmentField extends StatelessWidget { + const _AttachmentField({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(MailSearchTexts.attachmentLabel, style: _groupLabelStyle(theme)), + CheckboxListTile( + key: const ValueKey('search-condition-attachment'), + value: value, + onChanged: (bool? checked) => onChanged(checked ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + minTileHeight: AppSize.touchMin, + minLeadingWidth: 0, + horizontalTitleGap: AppSpacing.s10, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + title: Text( + MailSearchTexts.attachmentOnly, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ); + } +} + +/// 시트 내용을 놓는 모양 셋 — 자리가 줄어드는 차례 +enum _SheetLayout { + /// 머리 · 단추 줄 고정, 가운데만 스크롤 · 위 96 을 남긴다(보드 14 · 15) + fixedWithGap, + + /// 머리 · 단추 줄 고정, 가운데만 스크롤 · 위 96 을 남기지 않는다(시트가 화면 위까지) + fixedFull, + + /// 가운데를 제 높이대로 펼쳐 셋을 잇는다 — 바깥 스크롤이 시트 전체를 움직인다(머리 · 단추 줄도 함께) + scrolled, +} + +/// 시트의 세 칸 — 머리([top]) · 가운데 스크롤([middle]) · 단추 줄([bottom])을 위에서부터 잇는다(폭은 셋 다 꽉 채운다). +/// +/// 이번 레이아웃에서 잰 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기가 다 든다)로 모양을 한 번에 고른다 — +/// [available](자판 위 자리)에서 위 [gap] 과 머리 · 단추 줄을 빼고도 가운데에 [minMiddle](칸 하나)가 남으면 [_SheetLayout.fixedWithGap], +/// [gap] 을 빼지 않아야 남으면 [_SheetLayout.fixedFull], 그래도 모자라면 [_SheetLayout.scrolled]. +/// 판정은 바깥 자리와 머리 · 단추 줄의 높이로만 하므로 되풀이되지 않고(자리가 줄면 차례대로 넘어갈 뿐), 위젯 트리가 바뀌지 않아 +/// 칸이 새로 만들어지지 않는다 — 초점 · 입력 연결이 그대로다. 모양이 바뀌면 그 프레임 뒤에 [onLayoutChanged] 를 부른다. +/// 바깥 스크롤 안에 둔다(높이 제한 없이 받는다) +class _SheetBody extends MultiChildRenderObjectWidget { + _SheetBody({ + required this.available, + required this.gap, + required this.minMiddle, + required this.onLayoutChanged, + required Widget top, + required Widget middle, + required Widget bottom, + }) : super(children: [top, middle, bottom]); + + /// 시트가 쓸 수 있는 높이 — 화면(안전 영역 위)에서 자판을 뺀 자리 + final double available; + + /// 자리가 넉넉할 때 시트 위에 남기는 자리 + final double gap; + + /// 가운데에 이만큼도 남지 않으면 다음 모양으로 + final double minMiddle; + + /// 모양이 바뀐 뒤(그 프레임이 끝난 뒤) + final VoidCallback onLayoutChanged; + + @override + RenderObject createRenderObject(BuildContext context) => _RenderSheetBody( + available: available, + gap: gap, + minMiddle: minMiddle, + onLayoutChanged: onLayoutChanged, + ); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderSheetBody renderObject, + ) { + renderObject + ..available = available + ..gap = gap + ..minMiddle = minMiddle + ..onLayoutChanged = onLayoutChanged; + } +} + +class _SheetBodyParentData extends ContainerBoxParentData {} + +class _RenderSheetBody extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderSheetBody({ + required double available, + required double gap, + required double minMiddle, + required this.onLayoutChanged, + }) : _available = available, + _gap = gap, + _minMiddle = minMiddle; + + double _available; + set available(double value) { + if (value == _available) return; + _available = value; + markNeedsLayout(); + } + + double _gap; + set gap(double value) { + if (value == _gap) return; + _gap = value; + markNeedsLayout(); + } + + double _minMiddle; + set minMiddle(double value) { + if (value == _minMiddle) return; + _minMiddle = value; + markNeedsLayout(); + } + + VoidCallback onLayoutChanged; + + /// 지난 레이아웃의 모양 — 처음에는 없다(바뀜으로 치지 않는다) + _SheetLayout? _layout; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SheetBodyParentData) { + child.parentData = _SheetBodyParentData(); + } + } + + /// 셋을 재고(놓고) 이은 크기 — [place] 면 자리 · 모양도 정한다(마른 레이아웃은 재기만 한다) + Size _arrange( + BoxConstraints constraints, + ChildLayouter layoutChild, { + required bool place, + }) { + final RenderBox top = firstChild!; + final RenderBox middle = childAfter(top)!; + final RenderBox bottom = childAfter(middle)!; + final double width = constraints.maxWidth; + final BoxConstraints natural = BoxConstraints.tightFor(width: width); + final double topHeight = layoutChild(top, natural).height; + final double bottomHeight = layoutChild(bottom, natural).height; + final double room = _available - topHeight - bottomHeight; + final _SheetLayout layout = room - _gap >= _minMiddle + ? _SheetLayout.fixedWithGap + : room >= _minMiddle + ? _SheetLayout.fixedFull + : _SheetLayout.scrolled; + final double middleHeight = layoutChild(middle, switch (layout) { + _SheetLayout.fixedWithGap => BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: room - _gap, + ), + _SheetLayout.fixedFull => BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: room, + ), + _SheetLayout.scrolled => natural, + }).height; + if (place) { + (top.parentData! as _SheetBodyParentData).offset = Offset.zero; + (middle.parentData! as _SheetBodyParentData).offset = Offset( + 0, + topHeight, + ); + (bottom.parentData! as _SheetBodyParentData).offset = Offset( + 0, + topHeight + middleHeight, + ); + final _SheetLayout? previous = _layout; + _layout = layout; + if (previous != null && previous != layout) { + SchedulerBinding.instance.addPostFrameCallback( + (Duration _) => onLayoutChanged(), + debugLabel: 'SearchConditionsSheet.layoutChanged', + ); + } + } + return constraints.constrain( + Size(width, topHeight + middleHeight + bottomHeight), + ); + } + + @override + void performLayout() { + size = _arrange(constraints, ChildLayoutHelper.layoutChild, place: true); + } + + @override + Size computeDryLayout(covariant BoxConstraints constraints) => + _arrange(constraints, ChildLayoutHelper.dryLayoutChild, place: false); + + @override + double computeMinIntrinsicWidth(double height) => + _maxOfChildren((RenderBox child) => child.getMinIntrinsicWidth(height)); + + @override + double computeMaxIntrinsicWidth(double height) => + _maxOfChildren((RenderBox child) => child.getMaxIntrinsicWidth(height)); + + @override + double computeMinIntrinsicHeight(double width) => + _sumOfChildren((RenderBox child) => child.getMinIntrinsicHeight(width)); + + @override + double computeMaxIntrinsicHeight(double width) => + _sumOfChildren((RenderBox child) => child.getMaxIntrinsicHeight(width)); + + double _maxOfChildren(double Function(RenderBox child) measure) { + double result = 0; + for ( + RenderBox? child = firstChild; + child != null; + child = childAfter(child) + ) { + result = math.max(result, measure(child)); + } + return result; + } + + double _sumOfChildren(double Function(RenderBox child) measure) { + double result = 0; + for ( + RenderBox? child = firstChild; + child != null; + child = childAfter(child) + ) { + result += measure(child); + } + return result; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); + + @override + void paint(PaintingContext context, Offset offset) => + defaultPaint(context, offset); +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart index e928c1f..ac811ce 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -36,17 +36,25 @@ class MailSearchIntro extends StatelessWidget { } } -/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기] +/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 · +/// (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기]. +/// 도움말은 검색어 + 조건이면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.", +/// 검색어만이면 "다른 말로 찾아 보세요."(걸린 조건이 없으니 조건을 줄이라고 하지 않는다 — 웹과 같다) class MailSearchEmpty extends StatelessWidget { const MailSearchEmpty({ required this.query, required this.indexing, required this.onSearchAll, + this.hasConditions = false, super.key, }); + /// 찾은 검색어 — 조건만으로 찾았으면 빈 글 final String query; + /// 조건이 걸려 있었다 + final bool hasConditions; + /// 지난 메일을 검색할 수 있게 준비하는 중 — 그동안 찾지 못하는 말의 예를 더한다 final bool indexing; @@ -58,6 +66,11 @@ class MailSearchEmpty extends StatelessWidget { final ThemeData theme = Theme.of(context); final AppExtraColors extra = theme.extension()!; final VoidCallback? searchAll = onSearchAll; + final String hint = !hasConditions + ? MailSearchTexts.emptyWordsHint + : query.isEmpty + ? MailSearchTexts.emptyConditionsHint + : MailSearchTexts.emptyHint; return MailStateLayout( icon: Icons.search, iconColor: extra.textMuted, @@ -65,7 +78,7 @@ class MailSearchEmpty extends StatelessWidget { title: MailSearchTexts.emptyTitle(query), titleStyle: _stateTitleStyle(theme), children: [ - KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)), + KeepAllText(hint, style: _stateHintStyle(theme)), if (indexing) ...[ const SizedBox(height: AppSpacing.s8), KeepAllText( diff --git a/flutter/test/core/theme/app_theme_test.dart b/flutter/test/core/theme/app_theme_test.dart index ff02be3..490a252 100644 --- a/flutter/test/core/theme/app_theme_test.dart +++ b/flutter/test/core/theme/app_theme_test.dart @@ -28,6 +28,21 @@ void main() { expect(AppTypography.bodyMedium, 15); }); + test('아래 시트의 가림막은 대화상자 가림막 색 · 날짜 고르는 창의 머리 글은 22 · 600', () { + expect(theme.bottomSheetTheme.modalBarrierColor, AppColors.backdrop); + expect(theme.colorScheme.scrim, AppColors.backdrop); + expect( + theme.datePickerTheme.headerHeadlineStyle?.fontSize, + AppTypography.headline, + ); + expect(AppTypography.headline, 22); + expect( + theme.datePickerTheme.headerHeadlineStyle?.fontWeight, + AppTypography.semiBold, + ); + expect(AppTypography.semiBold, FontWeight.w600); + }); + test('단추의 누르는 곳은 48dp 이상이다', () { expect(AppSize.touchMin, 48); expect( diff --git a/flutter/test/features/mail/mail_search_conditions_test.dart b/flutter/test/features/mail/mail_search_conditions_test.dart new file mode 100644 index 0000000..49a8cb4 --- /dev/null +++ b/flutter/test/features/mail/mail_search_conditions_test.dart @@ -0,0 +1,1331 @@ +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 기기 시간대의 그날 0시를 UTC ISO 로 — 앱이 API 에 싣는 after · before 와 같은 셈(시험이 도는 곳의 시간대와 상관없다) +String _localIso(SearchDay day, {int addDays = 0}) => + DateTime(day.year, day.month, day.day + addDays).toUtc().toIso8601String(); + +/// 조건으로 찾기 (앱 보드 `App1SearchConditions` — 2차: [조건] · 시트 · 날짜 · 조건 줄 · 결과 글) +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + Finder sheet() => find.byType(MailSearchConditionsSheet); + Finder inSheet(Finder finder) => + find.descendant(of: sheet(), matching: finder); + Finder toggle() => + find.byKey(const ValueKey('mail-search-conditions')); + Finder textField(String name) => find.descendant( + of: find.byKey(ValueKey('search-condition-$name')), + matching: find.byType(TextField), + ); + Finder period() => + find.byKey(const ValueKey('search-condition-period')); + Finder attachment() => + find.byKey(const ValueKey('search-condition-attachment')); + Finder dateField(String edge) => + find.byKey(ValueKey('search-condition-$edge')); + Finder applyButton() => inSheet( + find.widgetWithText(FilledButton, MailSearchTexts.applyConditions), + ); + Finder sheetClear() => + inSheet(find.widgetWithText(TextButton, MailSearchTexts.clearConditions)); + Finder line() => find.byType(MailSearchConditionLine); + + /// 시트의 가운데(스크롤) — 끌면 칸이 움직인다(머리를 끌면 시트가 내려간다) + Finder middle() => + find.byKey(const ValueKey('search-conditions-middle')); + Finder lineClear() => find.descendant( + of: line(), + matching: find.widgetWithText(TextButton, MailSearchTexts.clearConditions), + ); + + String textOf(WidgetTester tester, String name) => + tester.widget(textField(name)).controller!.text; + + /// 기간 고르기 칸에 보이는 글 — 고른 기간 하나만 보인다(나머지 이름은 칸 안에 숨어 있다) + String periodShown(WidgetTester tester) { + final List shown = tester + .widgetList( + find.descendant(of: period(), matching: find.byType(Text)), + ) + .toList(); + expect(shown, hasLength(1)); + return shown.single.data!; + } + + bool attachmentOf(WidgetTester tester) => + tester.widget(attachment()).value!; + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + /// 오늘 — 앱이 보는 기기 시간대의 날짜(시험 시계) + SearchDay today() => SearchDay.of(h.clock.now); + + /// 보드의 받은편지함 — 이도윤이 보낸 첨부 있는 계약 메일 둘 · 인사팀 · 박지훈 + void seedInbox() { + final DateTime now = h.clock.now; + h.mail + ..put( + kInboxId, + messageJson( + 'd1', + mailboxId: kInboxId, + subject: 'Re: 유지보수 계약 갱신 건', + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + fromName: '인사팀', + fromEmail: 'hr@example.com', + subject: '[공지] 계약직 채용 일정 안내', + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'j1', + mailboxId: kInboxId, + fromName: '박지훈', + fromEmail: 'jihoon@example.com', + subject: '견적서 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 5)), + hasAttachment: true, + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'd2', + mailboxId: kInboxId, + subject: '계약서 초안 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 8)), + hasAttachment: true, + isRead: true, + ), + ); + } + + Future openSearch(WidgetTester tester, {Size size = kPhoneSize}) async { + await h.pump(tester, size: size); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + Future openSheet(WidgetTester tester) async { + await tester.tap(toggle()); + await tester.pumpAndSettle(); + } + + Future type(WidgetTester tester, String name, String text) async { + await tester.enterText(textField(name), text); + await tester.pump(); + } + + Future choosePeriod(WidgetTester tester, SearchPeriod value) async { + await tester.ensureVisible(period()); + await tester.pumpAndSettle(); + await tester.tap(period()); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailSearchTexts.period(value)).last); + await tester.pumpAndSettle(); + } + + Future toggleAttachment(WidgetTester tester) async { + await tester.ensureVisible(attachment()); + await tester.pumpAndSettle(); + await tester.tap(attachment()); + await tester.pumpAndSettle(); + } + + /// 날짜 칸을 눌러 날짜 고르는 창을 열고 [day] 일(이달 — 없으면 처음 날짜 그대로)을 골라 [확인] + Future pickDay(WidgetTester tester, String edge, {int? day}) async { + await tester.ensureVisible(dateField(edge)); + await tester.pumpAndSettle(); + await tester.tap(dateField(edge)); + await tester.pumpAndSettle(); + if (day != null) { + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('$day'), + ), + ); + await tester.pump(); + } + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + } + + Future apply(WidgetTester tester) async { + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + } + + /// 검색 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + group('위 막대의 [조건]', () { + testWidgets( + '맨 오른쪽 · 평소는 바탕 없이 "조건으로 찾기", 열면 옅은 청록 원 · 펼침, 조건이 걸리면 "조건이 걸려 있음"', + (WidgetTester tester) async { + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + // [✕] 오른쪽 — 맨 끝 + expect( + tester.getTopLeft(toggle()).dx, + greaterThan( + tester.getTopLeft(find.byTooltip(MailSearchTexts.clear)).dx, + ), + ); + IconButton button() => tester.widget(toggle()); + Semantics semanticsOf() => tester.widget( + find.ancestor(of: toggle(), matching: find.byType(Semantics)).first, + ); + expect(button().style!.backgroundColor, isNull); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditions, + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect(tester.getSize(toggle()), const Size.square(AppSize.touchMin)); + + await openSheet(tester); + expect(sheet(), findsOneWidget); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect(semanticsOf().properties.expanded, isTrue); + // 시트를 열면 칸의 초점을 [조건]이 받는다 — 닫혀도 자판이 다시 올라오지 않게 + expect(fieldFocused(tester), isFalse); + + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(sheet(), findsNothing); + expect(fieldFocused(tester), isFalse); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect( + button().style!.foregroundColor!.resolve({}), + AppColors.brandStrong, + ); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditionsLabel(applied: true), + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect( + MailSearchTexts.conditionsLabel(applied: true), + '조건으로 찾기, 조건이 걸려 있음', + ); + }, + ); + }); + + group('시트', () { + testWidgets( + '탭 막대까지 덮고 위 96 을 남긴다 — 머리(제목 · [✕] 닫기) · 손잡이는 낭독하지 않는다 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + expect( + tester.getTopLeft(sheet()).dy, + greaterThanOrEqualTo(AppSize.sheetTopGap), + ); + expect( + tester.getBottomLeft(sheet()).dy, + greaterThan(tester.getTopLeft(find.byType(NavigationBar)).dy), + ); + expect( + tester.getSemantics(inSheet(find.text(MailSearchTexts.conditions))), + isSemantics(label: MailSearchTexts.conditions, isHeader: true), + ); + expect( + tester.getSemantics( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ), + isSemantics(tooltip: MailSearchTexts.closeConditions, isButton: true), + ); + // 손잡이는 낭독하지 않는다 — Flutter 기본 손잡이의 "닫기" 단추가 없어 닫는 단추는 [✕] 하나다 + expect( + find.descendant( + of: sheet(), + matching: find.bySemanticsLabel(MailSearchTexts.closeConditions), + ), + findsNothing, + ); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + }, + ); + + testWidgets('가운데만 스크롤한다 — 스크롤되면 머리 아래 선, 단추 줄은 늘 아래', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + expect(headerLine(), Colors.transparent); + final double applyTop = tester.getTopLeft(applyButton()).dy; + await tester.drag(middle(), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + expect(tester.getTopLeft(applyButton()).dy, applyTop); + }); + + // 자판 · 좁은 화면 — 시트만 띄워 화면 크기 · 안전 영역 · 글자 크기를 정한다(앱 틀 없이) + Future pumpSheet( + WidgetTester tester, { + required Size size, + double safeTop = 0, + double safeBottom = 0, + double textScale = 1, + SearchConditions applied = SearchConditions.none, + String searchText = '', + }) async { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3; + tester.view.padding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.view.viewPadding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => showSearchConditionsSheet( + context, + applied: applied, + searchText: searchText, + clock: () => DateTime(2026, 11, 7, 10), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + /// 자판이 몇 프레임에 걸쳐 올라온다(그사이 시트의 자리가 줄어든다) + Future raiseKeyboard(WidgetTester tester, double height) async { + addTearDown(tester.view.resetViewInsets); + for (final double step in [height / 3, height * 2 / 3, height]) { + tester.view.viewInsets = FakeViewPadding( + bottom: step * tester.view.devicePixelRatio, + ); + await tester.pump(); + } + await tester.pumpAndSettle(); + } + + /// 「보낸 사람」에 초점을 두고 자판을 올린다 — 같은 칸에 초점 · 입력 연결이 남고, 칸이 자판 위에 보이고, 친 글이 그 칸에 든다 + Future typeWithKeyboard( + WidgetTester tester, { + required Size size, + required double keyboard, + }) async { + await pumpSheet(tester, size: size); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + + await raiseKeyboard(tester, keyboard); + expect(tester.takeException(), isNull); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + final Rect rect = tester.getRect(from); + expect(rect.top, greaterThanOrEqualTo(0)); + expect(rect.bottom, lessThanOrEqualTo(size.height - keyboard)); + + tester.testTextInput.enterText('이도윤'); + await tester.pump(); + expect(textOf(tester, 'from'), '이도윤'); + } + + testWidgets( + '세로 화면 — 칸에 초점을 두고 자판이 올라와도 초점 · 입력이 그대로이고 시트가 함께 올라간다(단추 줄은 자판 위)', + (WidgetTester tester) async { + await typeWithKeyboard(tester, size: kPhoneSize, keyboard: 300); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(kPhoneSize.height - 300), + ); + // 자리가 넉넉하면 머리는 고정 — 가운데만 스크롤한다 + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets( + '가로 화면 — 자판이 올라와 자리가 모자라면 시트 전체가 한 스크롤이 되어도 칸의 초점 · 입력이 그대로다(되풀이해 끊기지 않는다)', + (WidgetTester tester) async { + await typeWithKeyboard( + tester, + size: const Size(844, 390), + keyboard: 200, + ); + // 자판을 내리고 다시 올려도 같다 + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(tester.testTextInput.hasAnyClients, isTrue); + // 아래로 내려 [찾기]까지 간다 — 보낸 사람을 넣었으니 넘기고 닫는다 + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(390 - 200), + ); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('한 스크롤로 펼치면 머리 아래 선을 지운다 — 머리도 함께 움직이고 가운데에는 스크롤할 것이 없다', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + await tester.drag(middle(), const Offset(0, -100)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(headerLine(), Colors.transparent); + }); + + /// 낱말 넘김 알림 — 시트 안에 하나 + Finder wordsAlert() => inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + + /// [finder] 가 화면의 [top] ~ [bottom] 안에 다 보인다 + void expectShown( + WidgetTester tester, + Finder finder, { + required double top, + required double bottom, + }) { + final Rect rect = tester.getRect(finder); + expect(rect.top, greaterThanOrEqualTo(top)); + expect(rect.bottom, lessThanOrEqualTo(bottom)); + } + + for (final double scale in [1, 1.3, 2]) { + testWidgets( + '짧은 가로 화면(안전 영역 위아래 24 · 글자 $scale배)에서 낱말 알림이 떠도 넘치지 않고, 더 스크롤하지 않아도 알림 · [찾기]가 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + textScale: scale, + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expect(wordsAlert(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 24, bottom: 360 - 24); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + }, + ); + } + + testWidgets( + '알림이 붙어 모양이 넘어가는 크기(800×340)에서도 [찾기]를 누르면 스크롤하지 않고 알림 · [찾기]가 화면 안에 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(800, 340), + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + // 열 때는 위 96 을 남긴 고정 모양 — [찾기]가 처음부터 보인다 + expect(tester.getTopLeft(sheet()).dy, AppSize.sheetTopGap); + expectShown(tester, applyButton(), top: 0, bottom: 340); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expectShown(tester, wordsAlert(), top: 0, bottom: 340); + expectShown(tester, applyButton(), top: 0, bottom: 340); + // 알림만큼 단추 줄이 높아져 위 96 을 없앤 고정 모양으로 — 머리도 그대로 보인다(한 스크롤로 펼쳐 끝으로 내려가지 않았다) + expect(tester.getTopLeft(sheet()).dy, 0); + expectShown( + tester, + inSheet(find.text(MailSearchTexts.conditions)), + top: 0, + bottom: 340, + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets('한 스크롤로 펼친 모양(가로 + 자판)에서 글 칸의 [검색] 키로 띄운 알림도 자판 위에 보인다', ( + WidgetTester tester, + ) async { + await pumpSheet( + tester, + size: const Size(844, 390), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ), + ); + await tester.pump(); + await raiseKeyboard(tester, 200); + tester.testTextInput.enterText('아홉'); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 0, bottom: 390 - 200); + expectShown(tester, applyButton(), top: 0, bottom: 390 - 200); + }); + + testWidgets( + '펼치기 전에 위 96 부터 없앤다 — 780×360 · 안전 영역 24 는 머리 · 단추 줄 고정으로 열려 [찾기]가 처음부터 보이고 끌어 내려 닫힌다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + ); + // 시트가 화면 위(상태 막대 아래)까지 — 가운데는 칸 둘이 넘게 들어간다 + expect(tester.getTopLeft(sheet()).dy, 24); + expect( + tester.getSize(middle()).height, + greaterThan(AppSize.fieldHeight * 2), + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + await tester.drag( + inSheet(find.text(MailSearchTexts.conditions)), + const Offset(0, 360), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('자판을 내려 고정 모양으로 돌아와도 초점 칸이 보인다 — 가운데가 맨 위로 가 가려지지 않게', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder body = find.descendant( + of: find.byKey(const ValueKey('search-condition-body')), + matching: find.byType(EditableText), + ); + await tester.ensureVisible(body); + await tester.pumpAndSettle(); + await tester.tap(body); + await tester.pump(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expectShown(tester, body, top: 0, bottom: 390 - 200); + + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.widget(body).focusNode.hasFocus, isTrue); + final Rect viewport = tester.getRect(middle()); + expectShown(tester, body, top: viewport.top, bottom: viewport.bottom); + }); + + testWidgets( + '자판이 조금씩 올라와도 모양은 차례로 한 번씩만 넘어간다 — 시트 위가 되돌아 내려오지 않고 초점 · 입력이 그대로', + (WidgetTester tester) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + addTearDown(tester.view.resetViewInsets); + double previous = tester.getTopLeft(sheet()).dy; + final Set tops = {previous}; + for (double keyboard = 4; keyboard <= 200; keyboard += 4) { + tester.view.viewInsets = FakeViewPadding( + bottom: keyboard * tester.view.devicePixelRatio, + ); + await tester.pump(); + final double top = tester.getTopLeft(sheet()).dy; + expect(top, lessThanOrEqualTo(previous), reason: '자판 $keyboard'); + previous = top; + tops.add(top); + } + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + // 위 96 을 남긴 고정 → 위 96 없는 고정 → 한 스크롤(시트 위는 화면 위) + expect(tops, containsAll([AppSize.sheetTopGap, 0])); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + + testWidgets('열면 걸린 조건으로 채운다 — [찾기] 없이 닫으면([✕] · 뒤로) 고친 값을 버린다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + expect(textOf(tester, 'from'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + await type(tester, 'from', '이도윤'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.month)); + expect(attachmentOf(tester), isTrue); + + // 고치고 [✕] + await type(tester, 'from', '박지훈'); + await toggleAttachment(tester); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(attachmentOf(tester), isTrue); + + // 고치고 뒤로 가기 + await type(tester, 'subject', '견적'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'subject'), isEmpty); + // 닫기만 해서는 다시 찾지 않았다 + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets( + '[조건 지우기]는 칸만 비운다 — 기간 · 날짜 · 첨부까지, 시트는 열린 채 · 찾지 않음 · 초점은 그 단추', + (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'body', '단가'); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + await toggleAttachment(tester); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.ensureVisible(sheetClear()); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + expect(sheet(), findsOneWidget); + expect(textOf(tester, 'from'), isEmpty); + expect(textOf(tester, 'body'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + expect(dateField('since'), findsNothing); + expect(attachmentOf(tester), isFalse); + expect( + tester.widget(sheetClear()).focusNode!.hasFocus, + isTrue, + ); + expect(h.mail.searchQueries, hasLength(1)); + // 걸린 조건 · 조건 줄은 [찾기]를 누를 때까지 그대로 + expect(line(), findsOneWidget); + }, + ); + + testWidgets('[찾기]는 다듬은 조건을 걸고 칸의 지금 글로 찾는다 — 시트를 닫고 자판을 내리고 조건 줄', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', ' 이도윤 '); + await type(tester, 'subject', '갱신'); + await toggleAttachment(tester); + await apply(tester); + + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text('Re: 유지보수 계약 갱신 건'), findsOneWidget); + expect(find.text('계약서 초안 보내 드립니다'), findsNothing); + expect( + findCopy('조건: 이도윤 님이 보낸 메일, 제목에 ‘갱신’이 든 메일, 첨부 있음'), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + }); + + testWidgets('최근 N 은 넘길 때 그 날짜로 바꾼다 — after · before 는 기기 시간대의 0시(UTC)', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await apply(tester); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect(h.mail.searchQueries.single, { + 'after': _localIso(week.since!), + 'before': _localIso(week.until!, addDays: 1), + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(findCopy('조건: 최근 1주'), findsOneWidget); + }); + + testWidgets('낱말이 합쳐 8개를 넘으면 시트를 연 채 단추 줄 위에 알린다(요청 없음) — 낱말을 고치면 지운다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '견적 단가 납품 일정'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '계약 갱신 확인 요청 건'); + await apply(tester); + + expect(sheet(), findsOneWidget); + final Finder alert = inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + expect(alert, findsOneWidget); + expect(tester.getSemantics(alert), isSemantics(isLiveRegion: true)); + expect( + tester.getBottomLeft(alert).dy, + lessThan(tester.getTopLeft(applyButton()).dy), + ); + expect(h.mail.searchQueries, isEmpty); + + await type(tester, 'subject', '계약 갱신'); + expect(alert, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['subject'], '계약 갱신'); + }); + + testWidgets('기간 "날짜 고르기"면 날짜 칸 둘 — 최근 N 에서 바꾸면 그 기간의 날짜로 채워 둔다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + expect(dateField('since'), findsNothing); + // 고르기 칸은 "기간"과 고른 기간을 한 단추로 읽는다 · 높이 52 + expect( + tester.getSemantics(period()), + isSemantics( + label: + '${MailSearchTexts.periodLabel}\n${MailSearchTexts.period(SearchPeriod.all)}', + isButton: true, + ), + ); + expect(tester.getSize(period()).height, AppSize.fieldHeight); + expect(tester.getSize(textField('from')).height, AppSize.fieldHeight); + await choosePeriod(tester, SearchPeriod.custom); + // 전체 기간에서 바꾸면 빈 칸 — 오른쪽 달력 아이콘 + expect(dateField('since'), findsOneWidget); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find.descendant( + of: dateField('until'), + matching: find.byType(InkWell), + ), + ), + isSemantics(label: MailSearchTexts.until, isButton: true), + ); + + await choosePeriod(tester, SearchPeriod.week); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await choosePeriod(tester, SearchPeriod.custom); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(week.since!)), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.text(formatSearchDay(week.until!)), + ), + findsOneWidget, + ); + // 값이 있으면 오른쪽 [✕] — 그 날짜 지우기 + await tester.tap( + find.descendant( + of: dateField('since'), + matching: find.byTooltip(MailSearchTexts.clearSince), + ), + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + findsOneWidget, + ); + }); + + testWidgets( + '날짜 고르는 창 — 머리 "시작 날짜", 1970-01-01 ~ 오늘, 처음은 칸의 날짜 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await tester.ensureVisible(dateField('since')); + await tester.pumpAndSettle(); + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + final DatePickerDialog dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.helpText, MailSearchTexts.since); + expect(dialog.firstDate, DateTime(1970)); + expect(dialog.lastDate, today().toLocalDate()); + expect(dialog.initialDate, today().toLocalDate()); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('1'), + ), + ); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + final SearchDay first = SearchDay(today().year, today().month, 1); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(first)), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + value: formatSearchDay(first), + isButton: true, + ), + ); + + // 다시 열면 칸의 날짜에서 시작한다 + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(DatePickerDialog)) + .initialDate, + first.toLocalDate(), + ); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('날짜가 거꾸로면 고른 즉시 끝 날짜 칸이 빨갛고 두 칸 아래에 알린다 — [찾기]는 넘기지 않고 그 칸으로', ( + WidgetTester tester, + ) async { + // 작은 휴대폰 — 가운데를 맨 위로 올리면 끝 날짜 칸이 단추 줄 아래로 가려진다 + await openSearch(tester, size: const Size(390, 640)); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + expect(findCopy(MailSearchTexts.periodOrder), findsNothing); + await pickDay(tester, 'until', day: 1); + + final Finder error = inSheet(findCopy(MailSearchTexts.periodOrder)); + expect(error, findsOneWidget); + expect(tester.getSemantics(error), isSemantics(isLiveRegion: true)); + final Finder untilPick = find + .descendant(of: dateField('until'), matching: find.byType(InkWell)) + .first; + expect( + tester.getSemantics(untilPick), + isSemantics( + label: MailSearchTexts.until, + hint: MailSearchTexts.periodOrder, + validationResult: SemanticsValidationResult.invalid, + isButton: true, + ), + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + validationResult: SemanticsValidationResult.none, + isButton: true, + ), + ); + + // 가운데를 맨 위로 올려 끝 날짜 칸을 가려 두고 [찾기] — 그 칸이 보이게 스크롤하고 초점을 둔다 + await tester.drag(middle(), const Offset(0, 600)); + await tester.pumpAndSettle(); + expect( + tester.getRect(dateField('until')).bottom, + greaterThan(tester.getTopLeft(applyButton()).dy), + ); + await apply(tester); + expect(sheet(), findsOneWidget); + expect(h.mail.searchQueries, isEmpty); + expect(tester.widget(untilPick).focusNode!.hasFocus, isTrue); + expect( + tester.getRect(dateField('until')).bottom, + lessThanOrEqualTo(tester.getTopLeft(applyButton()).dy), + ); + + // 끝 날짜를 지우면 알림이 사라지고 넘긴다 — "…부터" + await tester.tap( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + ); + await tester.pumpAndSettle(); + expect(error, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['after'], _localIso(today())); + expect(h.mail.searchQueries.single.containsKey('before'), isFalse); + expect(findCopy('조건: ${formatSearchDay(today())}부터'), findsOneWidget); + }); + + testWidgets('글 칸의 자판은 검색 칸과 같은 결 — [검색] 키는 [찾기]', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + final TextField input = tester.widget(textField('to')); + expect(input.keyboardType, TextInputType.text); + expect(input.textInputAction, TextInputAction.search); + expect(input.autocorrect, isFalse); + expect(input.smartQuotesType, SmartQuotesType.disabled); + expect(input.smartDashesType, SmartDashesType.disabled); + expect( + tester.getSemantics(textField('to')).label, + contains(MailSearchTexts.toLabel), + ); + + await tester.enterText(textField('to'), '김아라'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['to'], '김아라'); + // 로그인한 직원의 이름 — "내가 받은 메일" + expect(findCopy('조건: 내가 받은 메일'), findsOneWidget); + }); + }); + + group('결과 화면', () { + testWidgets('조건만으로 찾으면 개수 "N통" · 조건 줄 — [조건 지우기]는 요청 없이 찾기 전으로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await toggleAttachment(tester); + await apply(tester); + + expect(h.mail.searchQueries.single.containsKey('q'), isFalse); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + expect(MailSearchTexts.count('', 2), '2통'); + expect(findCopy('조건: 이도윤 님이 보낸 메일, 첨부 있음'), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect( + tester.widget(toggle()).style!.backgroundColor, + isNull, + ); + }); + + testWidgets('조건 줄의 [조건 지우기]는 걸린 조건을 모두 빼고 검색어로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(line(), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + }); + + testWidgets('결과 없음 — 검색어 + 조건 · 조건만 · 검색어만이 각각 다른 글', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '해지'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + + await tester.enterText(field(), ''); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '최유나'); + await apply(tester); + expect(findCopy('조건에 맞는 메일이 없습니다'), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyConditionsHint), findsOneWidget); + expect(find.text(MailSearchTexts.count('', 0)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + await search(tester, '해지'); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget); + }); + + testWidgets( + '[검색] 키로 낱말이 합쳐 8개를 넘으면 결과 자리에 알린다(요청 없음) — 조건 줄과 [조건 지우기]는 남는다', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await type(tester, 'subject', '계약 갱신 확인'); + await apply(tester); + final int before = h.mail.searchQueries.length; + + await search(tester, '견적 단가 납품 일정 변경 요청'); + expect(h.mail.searchQueries, hasLength(before)); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy('검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.'), + findsOneWidget, + ); + expect(findCopy('조건: 제목에 ‘계약 갱신 확인’이 든 메일'), findsOneWidget); + + // 조건을 빼면 칸의 글(6낱말)로 찾는다 + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(before + 1)); + expect(h.mail.searchQueries.last['q'], '견적 단가 납품 일정 변경 요청'); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsNothing); + }, + ); + + testWidgets('[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 찾을 곳을 바꿔도 조건 그대로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(line(), findsOneWidget); + + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'limit': kMailPageSize, + }); + + // 당겨서 새로고침도 걸린 조건 그대로 + await tester.fling( + find.text('Re: 유지보수 계약 갱신 건'), + const Offset(0, 400), + 1000, + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['from'], '이도윤'); + }); + + testWidgets('검색어도 조건도 비운 [찾기]는 요청 없이 찾기 전으로', (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('결과에서 연 메일에는 검색어와 글 조건만 강조 쿼리로 싣는다 — 기간 · 첨부는 싣지 않는다', ( + WidgetTester tester, + ) async { + seedInbox(); + h.mail.searchHighlightField = true; + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '갱신'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await tester.tap(find.text('Re: 유지보수 계약 갱신 건')); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + }); + }); + + testWidgets('조건 줄은 최소 48 — 글이 길면 낱말 단위로 줄을 바꿔 늘어난다(잘리지 않는다) · [조건 지우기]는 48', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = kPhoneSize * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + Future heightOf(String text) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchConditionLine(text: text, onClear: () {}), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchConditionLine)).height; + } + + expect(await heightOf('조건: 최근 1달'), AppSize.touchMin); + expect( + await heightOf( + '조건: 이도윤 님이 보낸 메일, 제목에 ‘유지보수 계약 갱신’이 든 메일, 2026. 10. 1.부터 11. 5.까지, 첨부 있음', + ), + greaterThan(AppSize.touchMin), + ); + expect( + tester.getSize(find.byType(TextButton)).height, + greaterThanOrEqualTo(AppSize.touchMin), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_controller_test.dart b/flutter/test/features/mail/mail_search_controller_test.dart index 3a27c7f..a6cf4db 100644 --- a/flutter/test/features/mail/mail_search_controller_test.dart +++ b/flutter/test/features/mail/mail_search_controller_test.dart @@ -7,6 +7,7 @@ import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; @@ -141,7 +142,7 @@ void main() { List searchRequests() => [ for (final RequestOptions r in mail.listRequests) - if (r.queryParameters.containsKey('q')) r, + if (FakeMailBackend.isSearchQuery(r.queryParameters)) r, ]; MessageSummary summaryOf(Map json) => @@ -726,4 +727,166 @@ void main() { expect(stateOf(args).total, 21); }); }); + + group('조건 (2차 — 화면 상태에만 둔다)', () { + const SearchConditions fromDoyun = SearchConditions( + from: '이도윤', + hasAttachment: true, + ); + + test( + '시트의 [찾기]는 조건을 걸고 찾는다 — 빈 검색어는 싣지 않고, 이어 찾기 · 다시 찾기 · [다시 시도]도 걸린 조건 그대로', + () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(20), next: 'c1', total: 30) + : _page(_mails(10, from: 21)); + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit('', conditions: fromDoyun), isTrue); + await settle(args); + expect(stateOf(args).query, ''); + expect(stateOf(args).conditions, fromDoyun); + expect(searchRequests().single.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + + await controllerOf(args).loadMore(); + expect(searchRequests().last.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + 'cursor': 'c1', + }); + + await controllerOf(args).refresh(); + await settle(args); + expect(searchRequests().last.queryParameters['from'], '이도윤'); + expect(searchRequests().last.queryParameters.containsKey('q'), isFalse); + + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + mail.searchResponder = null; + await controllerOf(args).retry(); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '계약'); + expect(searchRequests().last.queryParameters['from'], '이도윤'); + }, + ); + + test( + '같은 검색어 · 같은 조건이면 보이는 채로 다시 찾고, 조건이 다르면 처음부터 찾는다 — 조건은 다듬어 건다', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf( + args, + ).submit('계약', conditions: const SearchConditions(from: ' 이도윤 ')); + expect(stateOf(args).conditions, const SearchConditions(from: '이도윤')); + await settle(args); + + controllerOf(args).submit('계약'); + expect(stateOf(args).refreshing, isTrue); + await settle(args); + + controllerOf( + args, + ).submit('계약', conditions: const SearchConditions(from: '박지훈')); + expect(stateOf(args).refreshing, isFalse); + expect(stateOf(args).phase, MailSearchPhase.searching); + await settle(args); + expect(searchRequests().last.queryParameters['from'], '박지훈'); + }, + ); + + test( + '[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 검색어도 조건도 없는 [찾기]는 요청 없이 찾기 전으로', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + + controllerOf(args).clear(); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions, fromDoyun); + // 찾기 전에 찾을 곳만 바꾸면 곳만 바꿔 둔다 + expect(controllerOf(args).setScope(all: true, fieldText: ''), isFalse); + + expect(controllerOf(args).submit(' '), isTrue); + await settle(args); + expect(searchRequests().last.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'limit': kMailPageSize, + }); + + final int before = searchRequests().length; + expect( + controllerOf(args).submit('', conditions: SearchConditions.none), + isTrue, + ); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before)); + // 그 뒤의 빈 칸 [검색]은 아무 일도 없다 + expect(controllerOf(args).submit(''), isFalse); + }, + ); + + test( + '조건 줄의 [조건 지우기] — 칸의 글(비었으면 보이는 검색어)로 조건 없이 다시, 검색어가 없으면 요청 없이 찾기 전, 찾기 전이면 조건만 뺀다', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + expect(controllerOf(args).clearConditions(fieldText: ''), isTrue); + await settle(args); + expect(searchRequests().last.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(stateOf(args).conditions.isEmpty, isTrue); + + controllerOf(args).submit('', conditions: fromDoyun); + await settle(args); + final int before = searchRequests().length; + controllerOf(args).clearConditions(fieldText: ''); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before)); + + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + controllerOf(args).clear(); + expect(controllerOf(args).clearConditions(fieldText: '견적'), isFalse); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before + 1)); + }, + ); + + test('검색어와 걸린 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다 — 조건은 걸린 채', () async { + final MailSearchArgs args = await open(); + controllerOf( + args, + ).submit('', conditions: const SearchConditions(subject: '계약 갱신 확인')); + await settle(args); + final int before = searchRequests().length; + expect(controllerOf(args).submit('견적 단가 납품 일정 변경 요청'), isTrue); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.tooManyWords); + expect(stateOf(args).conditions.subject, '계약 갱신 확인'); + expect(searchRequests(), hasLength(before)); + }); + }); } diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart index d180816..35bd524 100644 --- a/flutter/test/features/mail/mail_search_page_test.dart +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -472,7 +472,7 @@ void main() { expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget); expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget); - expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget); expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); await tester.tap(find.text(MailSearchTexts.searchAll)); diff --git a/flutter/test/features/mail/mail_search_view_test.dart b/flutter/test/features/mail/mail_search_view_test.dart index 5a1c069..2db6708 100644 --- a/flutter/test/features/mail/mail_search_view_test.dart +++ b/flutter/test/features/mail/mail_search_view_test.dart @@ -1,6 +1,7 @@ import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; @@ -303,4 +304,80 @@ void main() { expect(old.scopeMailboxId, isNull); }); }); + + group('조건 (2차)', () { + const SearchConditions fromDoyun = SearchConditions( + from: '이도윤', + subject: '갱신', + since: SearchDay(2026, 10, 6), + hasAttachment: true, + ); + + test('조건만으로 찾았으면 머리는 "2통" · "찾는 중", 전체 수를 모르면 머리가 없다', () { + MailSearchState conditionsOnly({int? total, bool refreshing = false}) => + MailSearchState( + phase: MailSearchPhase.ready, + query: '', + pageInfo: MailPageInfo(hasNext: false, total: total), + conditions: fromDoyun, + refreshing: refreshing, + ); + expect(searchCountLineOf(conditionsOnly(total: 2)), '2통'); + expect( + searchCountLineOf(conditionsOnly(total: 2, refreshing: true)), + '찾는 중', + ); + expect(searchCountLineOf(conditionsOnly()), isNull); + }); + + test('한 글자 안내는 본문을 찾을 말(검색어 · 내용 조건)이 한 글자뿐일 때만', () { + MailSearchState ready(String query, SearchConditions conditions) => + MailSearchState( + phase: MailSearchPhase.ready, + query: query, + conditions: conditions, + indexStatus: SearchIndexStatus.ready, + ); + expect( + searchNoticeOf(ready('', const SearchConditions(body: '가'))), + MailSearchNotice.singleChar, + ); + expect( + searchNoticeOf(ready('', const SearchConditions(from: '김'))), + isNull, + ); + expect( + searchNoticeOf(ready('김', const SearchConditions(subject: '견적'))), + isNull, + ); + }); + + test('결과에서 연 메일에는 검색어와 글 조건만 — 실을 칸이 없으면 강조 쿼리 없이', () { + const MailSearchState state = MailSearchState( + phase: MailSearchPhase.ready, + query: '계약', + searchedMailboxId: kInboxId, + pageInfo: MailPageInfo(hasNext: false, total: 1), + conditions: fromDoyun, + ); + expect( + searchOpenOf(_item('m1', mailboxId: kInboxId), state).highlight, + const MessageHighlightQuery(q: '계약', from: '이도윤', subject: '갱신'), + ); + const MailSearchState datesOnly = MailSearchState( + phase: MailSearchPhase.ready, + query: '', + searchedMailboxId: kInboxId, + pageInfo: MailPageInfo(hasNext: false, total: 1), + conditions: SearchConditions( + since: SearchDay(2026, 10, 6), + hasAttachment: true, + ), + ); + expect( + searchOpenOf(_item('m1', mailboxId: kInboxId), datesOnly).highlight, + isNull, + ); + }); + }); } diff --git a/flutter/test/features/mail/search_conditions_test.dart b/flutter/test/features/mail/search_conditions_test.dart new file mode 100644 index 0000000..4d02e35 --- /dev/null +++ b/flutter/test/features/mail/search_conditions_test.dart @@ -0,0 +1,456 @@ +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 시험 날짜 — 'YYYY-MM-DD' 를 [SearchDay] 로 +SearchDay _day(String text) { + final List parts = text.split('-').map(int.parse).toList(); + return SearchDay(parts[0], parts[1], parts[2]); +} + +/// 걸린 조건 — 웹 spec 의 `conditions(partial)` 과 같은 모양 +SearchConditions _conditions({ + String from = '', + String to = '', + String subject = '', + String body = '', + String? since, + String? until, + bool hasAttachment = false, +}) => SearchConditions( + from: from, + to: to, + subject: subject, + body: body, + since: since == null ? null : _day(since), + until: until == null ? null : _day(until), + hasAttachment: hasAttachment, +); + +/// KST(UTC+9)로 고정한 그날 0시 — 기기 시간대에 기대지 않고 after · before 를 본다 +DateTime _kstMidnight(int year, int month, int day) => + DateTime.utc(year, month, day).subtract(const Duration(hours: 9)); + +/// 조건 검색 (2차 — 계약 2절 "검색 — 14단계"). **짝: 웹 `frontend/src/shared/utils/mail-search.spec.ts`** 의 같은 경우를 옮겼다. +/// 오늘은 지역 시각(`DateTime(년, 월, 일)`)으로 넘긴다 — 시간대(UTC · KST)와 상관없이 같은 날이다 +void main() { + group('조건 다듬기 · 같은 조건', () { + test('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만', () { + expect( + normalizeSearchConditions( + _conditions( + from: ' 이도윤 ', + subject: '가' * 250, + since: '2026-02-30', + until: '2026-11-05', + hasAttachment: true, + ), + ), + _conditions( + from: '이도윤', + subject: '가' * 200, + until: '2026-11-05', + hasAttachment: true, + ), + ); + expect(normalizeSearchConditions(null), SearchConditions.none); + }); + + test('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () { + expect(isSearchDay(_day('1970-01-01')), isTrue); + expect(isSearchDay(_day('9999-12-30')), isTrue); + expect(isSearchDay(_day('1969-12-31')), isFalse); + // 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다 + expect(isSearchDay(_day('9999-12-31')), isFalse); + expect(isSearchDay(_day('10000-01-01')), isFalse); + expect(isSearchDay(_day('2024-02-29')), isTrue); + expect(isSearchDay(_day('2026-02-29')), isFalse); + expect( + normalizeSearchConditions( + _conditions(since: '1969-12-31', until: '2026-11-01'), + ), + _conditions(until: '2026-11-01'), + ); + expect( + normalizeSearchConditions( + _conditions(since: '2026-11-01', until: '9999-12-31'), + ), + _conditions(since: '2026-11-01'), + ); + }); + + test('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () { + expect( + normalizeSearchConditions( + _conditions( + since: '2026-11-06', + until: '2026-10-01', + hasAttachment: true, + ), + ), + _conditions(hasAttachment: true), + ); + expect( + normalizeSearchConditions( + _conditions(since: '2026-11-06', until: '2026-11-06'), + ), + _conditions(since: '2026-11-06', until: '2026-11-06'), + ); + }); + + test('조건이 하나라도 있는가 · 같은 조건인가 — 빈 조건은 조건 없음과 같다', () { + expect(SearchConditions.none.isEmpty, isTrue); + expect(_conditions().isEmpty, isTrue); + expect(_conditions(hasAttachment: true).isNotEmpty, isTrue); + expect(_conditions(until: '2026-11-05').isNotEmpty, isTrue); + expect(_conditions(), SearchConditions.none); + expect(_conditions(from: '이도윤'), _conditions(from: '이도윤')); + expect(_conditions(from: '이도윤'), isNot(_conditions(to: '이도윤'))); + expect(_day('2026-11-05').toString(), '2026-11-05'); + }); + }); + + group('API 쿼리 · 읽기 화면의 강조 쿼리', () { + test( + '기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 기기 시간대에서 UTC 로 — 첨부는 hasAttachment=true', + () { + final SearchConditions value = _conditions( + to: 'ara.kim', + subject: '견적', + since: '2026-10-06', + until: '2026-11-06', + hasAttachment: true, + ); + expect( + searchConditionsParams(value, midnight: _kstMidnight), + { + 'to': 'ara.kim', + 'subject': '견적', + 'after': '2026-10-05T15:00:00.000Z', + 'before': '2026-11-06T15:00:00.000Z', + 'hasAttachment': 'true', + }, + ); + // 기본은 기기 시간대 — 그날 0시의 UTC (웹 spec 의 localMidnightIso 와 같은 셈) + expect(searchConditionsParams(value)['after'], _localIso(2026, 10, 6)); + expect(searchConditionsParams(value)['before'], _localIso(2026, 11, 7)); + }, + ); + + test('시작만 · 끝만도 된다 — 끝 날짜가 그 달 · 그 해의 마지막 날이면 다음 달 · 해로 넘긴다', () { + expect( + searchConditionsParams( + _conditions(until: '2026-12-31'), + midnight: _kstMidnight, + ), + {'before': '2026-12-31T15:00:00.000Z'}, + ); + expect( + searchConditionsParams(_conditions(until: '2026-12-31')), + {'before': _localIso(2027, 1, 1)}, + ); + expect( + searchConditionsParams( + _conditions(since: '2026-03-01'), + midnight: _kstMidnight, + ), + {'after': '2026-02-28T15:00:00.000Z'}, + ); + expect(searchConditionsParams(SearchConditions.none), isEmpty); + }); + + test('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () { + final SearchConditions value = _conditions( + from: '이도윤', + body: '계약', + since: '2026-10-06', + hasAttachment: true, + ); + expect( + messageHighlightQueryOf('갱신', value), + const MessageHighlightQuery(q: '갱신', from: '이도윤', body: '계약'), + ); + expect( + messageHighlightQueryOf('', _conditions(subject: '견적')), + const MessageHighlightQuery(subject: '견적'), + ); + expect( + messageHighlightQueryOf( + '', + _conditions(since: '2026-10-06', hasAttachment: true), + ), + isNull, + ); + expect( + messageHighlightQueryOf( + '계약', + SearchConditions.none, + )!.toQueryParameters(), + {'q': '계약'}, + ); + }); + }); + + group('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () { + test('띄어쓰기로 나눈 조각을 칸마다 세어 더한다 — 날짜 · 첨부는 낱말이 아니다', () { + final SearchConditions value = _conditions( + from: '이도윤', + to: '김아라 박지훈', + subject: '"계약 갱신"', + body: '단가', + ); + expect(combinedSearchWords('견적 회의', value), 8); + expect(exceedsCombinedSearchWords('견적 회의', value), isFalse); + expect(exceedsCombinedSearchWords('견적 회의 일정', value), isTrue); + expect( + combinedSearchWords( + '', + _conditions(since: '2026-10-06', hasAttachment: true), + ), + 0, + ); + }); + }); + + group('기간 고르기', () { + final DateTime today = DateTime(2026, 11, 6, 15, 30); + + test('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () { + expect(periodRange(SearchPeriod.week, today), ( + since: _day('2026-10-30'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.month, today), ( + since: _day('2026-10-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.quarter, today), ( + since: _day('2026-08-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.year, today), ( + since: _day('2025-11-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.all, today), (since: null, until: null)); + expect(periodRange(SearchPeriod.custom, today), ( + since: null, + until: null, + )); + }); + + test('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () { + expect( + periodRange(SearchPeriod.month, DateTime(2026, 3, 31)).since, + _day('2026-02-28'), + ); + expect( + periodRange(SearchPeriod.month, DateTime(2028, 3, 31)).since, + _day('2028-02-29'), + ); + expect( + periodRange(SearchPeriod.quarter, DateTime(2026, 5, 31)).since, + _day('2026-02-28'), + ); + expect( + periodRange(SearchPeriod.week, DateTime(2026, 1, 3)).since, + _day('2025-12-27'), + ); + expect( + periodRange(SearchPeriod.year, DateTime(2028, 2, 29)).since, + _day('2027-02-28'), + ); + }); + + test('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () { + expect(periodOf(null, null, today), SearchPeriod.all); + expect( + periodOf(_day('2026-10-06'), _day('2026-11-06'), today), + SearchPeriod.month, + ); + expect( + periodOf(_day('2026-10-30'), _day('2026-11-06'), today), + SearchPeriod.week, + ); + // 다음 날 보면 같은 날짜도 날짜 고르기다 + expect( + periodOf(_day('2026-10-06'), _day('2026-11-06'), DateTime(2026, 11, 7)), + SearchPeriod.custom, + ); + expect(periodOf(_day('2026-10-01'), null, today), SearchPeriod.custom); + }); + + test('시작이 끝보다 늦으면 거꾸로다 — 같은 날 · 한쪽만 있으면 아니다', () { + expect(isReversedPeriod(_day('2026-11-05'), _day('2026-10-01')), isTrue); + expect(isReversedPeriod(_day('2026-10-01'), _day('2026-10-01')), isFalse); + expect(isReversedPeriod(_day('2026-11-05'), null), isFalse); + expect(isReversedPeriod(null, _day('2026-10-01')), isFalse); + }); + }); + + group('조건 줄 글', () { + final DateTime today = DateTime(2026, 11, 6); + + test('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () { + expect( + searchConditionsSummary( + _conditions( + from: '이도윤', + since: '2026-10-06', + until: '2026-11-06', + hasAttachment: true, + ), + today, + ), + '조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음', + ); + }); + + test('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () { + expect( + searchConditionsSummary( + _conditions( + to: '김아라', + subject: '견적', + body: '계약서', + since: '2026-10-01', + until: '2026-11-05', + ), + today, + ), + '조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지', + ); + expect( + searchConditionsSummary( + _conditions(since: '2025-12-01', until: '2026-01-05'), + today, + ), + '조건: 2025. 12. 1.부터 2026. 1. 5.까지', + ); + expect( + searchConditionsSummary(_conditions(since: '2026-10-01'), today), + '조건: 2026. 10. 1.부터', + ); + expect( + searchConditionsSummary(_conditions(until: '2026-10-31'), today), + '조건: 2026. 10. 31.까지', + ); + // 소리를 알 수 없는 끝 글자(라틴)는 "이(가)" + expect( + searchConditionsSummary(_conditions(subject: 'ERP'), today), + '조건: 제목에 ‘ERP’이(가) 든 메일', + ); + expect(searchConditionsSummary(SearchConditions.none, today), ''); + }); + + test('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () { + expect( + searchConditionsSummary( + _conditions(since: '2026-11-06', until: '2026-11-06'), + today, + ), + '조건: 2026. 11. 6. 하루', + ); + expect( + searchConditionsSummary( + _conditions(since: '2025-12-31', until: '2025-12-31'), + today, + ), + '조건: 2025. 12. 31. 하루', + ); + }); + + test( + '보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다', + () { + const SearchConditionsMe me = ( + name: '김아라', + email: 'ara.kim@arasystem.test', + ); + expect( + searchConditionsSummary( + _conditions(from: '김아라', to: ' ARA.KIM@arasystem.test '), + today, + me: me, + ), + '조건: 내가 보낸 메일, 내가 받은 메일', + ); + expect( + searchConditionsSummary( + _conditions(from: 'ara.kim@arasystem.test'), + today, + me: me, + ), + '조건: 내가 보낸 메일', + ); + expect( + searchConditionsSummary( + _conditions(from: '아라', to: 'ara.kim'), + today, + me: me, + ), + '조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일', + ); + // 누구인지 모르면 지금 글 그대로 + expect( + searchConditionsSummary(_conditions(from: '김아라'), today), + '조건: 김아라 님이 보낸 메일', + ); + }, + ); + + test('"나"는 풀어 쓴 한글(맥에서 온 글)도 모아서 견준다 — NFC', () { + // 김아라 를 첫소리 · 가운뎃소리 · 끝소리 자모로 풀어 쓴 글 + final String decomposed = String.fromCharCodes([ + 0x1100, 0x1175, 0x11B7, // 김 + 0x110B, 0x1161, // 아 + 0x1105, 0x1161, // 라 + ]); + expect(decomposed, isNot('김아라')); + expect( + isSearchConditionMe(decomposed, (name: '김아라', email: 'ara@x.test')), + isTrue, + ); + expect( + isSearchConditionMe('김아', (name: '김아라', email: 'ara@x.test')), + isFalse, + ); + expect(isSearchConditionMe('김아라', null), isFalse); + expect( + isSearchConditionMe(' ', (name: '', email: 'ara@x.test')), + isFalse, + ); + }); + + test('날짜 칸 · 조건 줄의 날짜는 "2026. 11. 7." — 해를 뺄 수 있다', () { + expect(formatSearchDay(_day('2026-11-07')), '2026. 11. 7.'); + expect(formatSearchDay(_day('2026-01-05'), withYear: false), '1. 5.'); + expect(MailSearchTexts.period(SearchPeriod.quarter), '최근 3달'); + }); + }); + + group('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () { + test('검색어나 내용 조건이 모두 한 글자면 그렇다', () { + expect(skipsBodySearch('김', SearchConditions.none), isTrue); + expect(skipsBodySearch('', _conditions(body: '가')), isTrue); + expect(skipsBodySearch('김', _conditions(from: '이')), isTrue); + }); + + test('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () { + expect(skipsBodySearch('김 계약', SearchConditions.none), isFalse); + expect(skipsBodySearch('김', _conditions(subject: '견적')), isFalse); + // 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다 + expect(skipsBodySearch('', _conditions(from: '김')), isFalse); + expect( + skipsBodySearch('', _conditions(subject: '가', since: '2026-10-01')), + isFalse, + ); + }); + }); +} + +/// 기기 시간대의 그날 0시를 UTC ISO 로 — 시험이 도는 곳의 시간대와 상관없이 앱과 같은 셈 +String _localIso(int year, int month, int day) => + DateTime(year, month, day).toUtc().toIso8601String(); diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index 9e91c08..b1f0cdb 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -196,10 +196,26 @@ class FakeMailBackend { 'total': 10, }); + /// 검색 요청의 쿼리 이름 — 이 가운데 하나라도 있으면 검색이다(검색어 또는 조건 — 계약 2절 "검색 — 14단계") + static const Set searchKeys = { + 'q', + 'from', + 'to', + 'subject', + 'body', + 'after', + 'before', + 'hasAttachment', + }; + + /// 검색 요청인가 — 메일함 목록 요청과 가른다 + static bool isSearchQuery(Map query) => + query.keys.any(searchKeys.contains); + /// 받은 검색 요청의 쿼리 (차례대로) List> get searchQueries => >[ for (final RequestOptions request in listRequests) - if (request.queryParameters.containsKey('q')) request.queryParameters, + if (isSearchQuery(request.queryParameters)) request.queryParameters, ]; /// 받은 `GET /search-index` 의 수 @@ -256,7 +272,7 @@ class FakeMailBackend { }); }); server.on('GET', MailApiPaths.messages, (RequestOptions options) async { - if (options.queryParameters.containsKey('q')) { + if (isSearchQuery(options.queryParameters)) { final Completer? gate = searchGate; if (gate != null) { searchGate = null; @@ -360,9 +376,10 @@ class FakeMailBackend { /// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을 /// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) — /// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다. + /// 조건([_matchesConditions] — 보낸 사람 · 받는 사람 · 제목 · 내용(미리 보기로 대신) · 받은 시각 · 첨부)도 거른다. q 가 없으면 조건만으로. /// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다 FakeResponse defaultSearch(Map query) { - final String q = query['q']! as String; + final String? q = query['q'] as String?; final Object? mailboxId = query['mailboxId']; final List> pool = >[ for (final MapEntry>> entry @@ -380,8 +397,10 @@ class FakeMailBackend { pool .where( (Map m) => - (m['subject']! as String).contains(q) || - (m['preview']! as String).contains(q), + (q == null || + (m['subject']! as String).contains(q) || + (m['preview']! as String).contains(q)) && + _matchesConditions(m, query), ) .toList() ..sort((Map a, Map b) { @@ -416,6 +435,39 @@ class FakeMailBackend { }); } + /// 기본 검색의 조건 — 글 조건은 그 칸에 그 글이 들었는지(보낸 사람 · 받는 사람은 이름이나 주소, 내용은 미리 보기로 대신), + /// 기간은 받은 시각이 `after` 이상 · `before` 미만(둘 다 UTC ISO 라 글로 견준다), 첨부는 `hasAttachment=true` 일 때 있는 메일만 + static bool _matchesConditions( + Map message, + Map query, + ) { + bool contains(Object? value, Object? text) => + text is! String || (value is String && value.contains(text)); + bool person(Object? address, Object? text) => + address is Map && + (contains(address['name'], text) || contains(address['email'], text)); + final Object? from = query['from']; + if (from != null && !person(message['from'], from)) return false; + final Object? to = query['to']; + if (to != null && + !(message['to']! as List).any( + (Object? address) => person(address, to), + )) { + return false; + } + if (!contains(message['subject'], query['subject'])) return false; + if (!contains(message['preview'], query['body'])) return false; + final String receivedAt = message['receivedAt']! as String; + final Object? after = query['after']; + if (after is String && receivedAt.compareTo(after) < 0) return false; + final Object? before = query['before']; + if (before is String && receivedAt.compareTo(before) >= 0) return false; + if (query['hasAttachment'] == 'true' && message['hasAttachment'] != true) { + return false; + } + return true; + } + /// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽) static String searchCursorAfter(Map message) => 's:${message['receivedAt']}|${message['id']}'; @@ -463,7 +515,7 @@ class FakeMailBackend { final Map? detail = detailJsonOf( id, showImages: images == kMailImagesShow, - highlighted: options.queryParameters.containsKey('q'), + highlighted: isSearchQuery(options.queryParameters), ); return detail == null ? FakeResponse.fail(404, 'RES_001') -- 2.52.0 From 9c5db05ada03393aa5d17ea6e7828456f6d39868 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 19:00:33 +0900 Subject: [PATCH 24/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?2=EC=B0=A8(=EC=A1=B0=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0)=EB=A5=BC=20=EC=95=B1=20README=20=C2=B7=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=C2=B7=20flutter=20=EC=8A=A4=ED=82=AC=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조건 시트 · 날짜 · 조건 줄 · 낱말 넘김 · 시트의 세 모양 · 가림막처럼 구현하며 정한 것과 웹과 다른 점(검색어도 조건도 비면 찾기 전 화면 · 시트 안 낱말 알림 · 날짜는 오늘까지)을 적고, 1차 문서의 "조건으로 찾기는 2차"와 미룬 것을 고친다. 조건 칸은 14단계 서버부터 받는다는 것과 알려진 한계 둘(라틴 글자의 "나" 견주기 · 한 스크롤 모양에서 끌어 내려 닫기)도 남긴다. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 2 +- docs/design/app/README.md | 41 +++++++++++++++++++++++++++++---- docs/mail/api-contract.md | 11 +++++---- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 8965f1f..e4c0502 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -28,7 +28,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` | | 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) | | **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` | -| **메일 검색 (`App1Search` — 1차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar` · `mail_search_states` · `highlighted_text`) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` | +| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` | | HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 47948f1..f0b8f7c 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -182,12 +182,12 @@ 고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분), 실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글. -### 검색 (`App1Search` · `App1SearchStates` — 1차, 2026-11-07 확정) +### 검색 (`App1Search` · `App1SearchStates` — 1차 · `App1SearchConditions` — 2차, 2026-11-07 확정) -웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 조건으로 찾기(바닥 시트)는 2차다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로"). +웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 2차는 조건으로 찾기(바닥 시트 — 아래 「조건으로 찾기」)다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로"). - **자리**: 목록 위 막대의 [찾기]가 연다. 탭 안 화면 `/mail/search` 라 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫히고, 다른 탭에 다녀오면 결과가 남는다. 검색어는 주소에 싣지 않는다(시작한 메일함만 라우트 `extra`). -- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다. 찾으면 자판을 내리고, 결과를 끌어도 내린다. +- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점 — 걸린 조건은 남는다) · [조건](맨 오른쪽 — 2차). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다(조건이 걸려 있으면 조건만으로 찾는다 — 웹과 같다). 찾으면 자판을 내리고, 결과를 끌어도 내린다. 칸은 글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표와 대시 끔 · 두 줄까지 · 줄바꿈은 띄어쓰기 · UTF-16 200자다. 칸 이름은 낭독에서 한 번만 읽는다(보이는 안내 글은 낭독에서 뺀다). - **찾을 곳 줄 56**: "찾을 곳" + [시작한 메일함 | 전체 메일함](고른 칸은 옅은 청록 바탕 · 굵은 글, ✓ 없음). 찾기 전부터 보이고, 바꾸면 칸의 글로 바로 다시 찾는다. 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". 휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다. - **결과 머리 36**(최소 — 큰 글자면 늘어난다): "‘계약’ · 4통" · 찾는 동안 "· 찾는 중" · 전체 수를 모르면 검색어만. 그 아래 안내 줄은 하나다(검색 준비 중 또는 한 글자 안내 — 함께 뜨지 않고, 준비 상태를 받지 못하면 없다). @@ -198,6 +198,32 @@ - **결과에서 읽기**: 누르면 읽기 화면이다(임시보관함은 쓰기 화면). 머리의 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다(본문 mark 는 웹과 같다 — 글자색 그대로 · 모서리 2 · 좌우 1px). 지우고 돌아오면 결과 아래에 [되돌리기] 알림이 뜬다. - **이어 찾기**: 한 차례 5번 · 20통이다. 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면, 있으면 목록 끝 줄). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 저절로 묻는다. - **결과 갱신**: 새 메일 확인으로 다시 찾지 않는다. 읽기 화면이 바꾼 표시 · 메일함 · 붙잡힘만 덮고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. 당겨서 새로고침은 다시 찾기다. +- **조건으로 찾기**(2차 — `App1SearchConditions`, 2026-11-07 확정 · 웹 「14단계 구현하며 정한 보완 — 검색」의 [조건] 패널을 바닥 시트로 옮겼다) + - **[조건]**: 위 막대 맨 오른쪽에 고정한다(보이는 원 40 · 누르는 곳 48 · 가로 슬라이더 아이콘). 계획은 [✕] 왼쪽이었지만 글을 쓰기 시작할 때 옆으로 밀리지 않게 바꿨다. + 평소 바탕 없음, 시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원. 낭독 "조건으로 찾기"(+ 펼침 상태), 걸려 있으면 ", 조건이 걸려 있음". + - **시트**: 탭 막대까지 덮는 바닥 시트(위 96 을 남김 · 위 모서리 16). 손잡이는 직접 그리고 낭독에서 뺀다(Flutter 기본 손잡이는 "닫기" 단추로 읽혀 [✕]와 겹친다). + 머리 "조건으로 찾기" + [✕](닫기)와 아래 단추 줄([조건 지우기] · [찾기])은 고정이고 가운데만 스크롤한다(스크롤되면 머리 아래 선). 자판이 올라오면 시트도 올라간다. + 모양은 셋이다 — 자판 위 자리에서 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기 포함)와 위 96 을 빼도 가운데에 칸 하나(52 × 글자 배율)가 남으면 위 96 을 남긴 고정, 96 을 쓰면 남으면 시트를 화면 위까지 올린 고정, 그래도 모자라면(가로 화면 + 자판 약 170 이상 등) 머리 · 단추 줄까지 시트 전체가 한 스크롤이다. 판정은 같은 레이아웃 안에서 한 번에 하고(`_SheetBody`), 트리는 그대로라 칸의 초점 · 입력이 끊기지 않는다. 모양이 바뀌면 초점 칸을, 낱말 알림이 뜨면 알림과 단추 줄을 보이게 스크롤한다. 열 때 초점을 [조건]으로 옮겨 자판을 내린다(닫은 뒤 자판이 다시 올라오지 않게). + - **칸**: 보낸 사람 · 받는 사람 · 제목 · 내용(글 칸 52 — 검색 칸과 같은 자판이고 줄바꿈은 띄어쓰기라 두 줄까지 는다, 자판의 [검색] = [찾기]) · + 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 — "기간, 고른 값"을 한 단추로 읽는다) · 날짜 고르기면 시작 날짜 · 끝 날짜 두 칸 · 첨부(체크 줄 "첨부 파일이 있는 메일만") · 안내 글(첫 화면에서는 접힘 아래). + - **날짜**: Flutter 날짜 창이다 — 1970년 1월 1일부터 오늘까지만, 처음 날짜는 칸의 값을 범위 안으로, 머리 큰 글 22 · 굵기 600 은 테마(`datePickerTheme`). + 값은 "2026. 11. 7." 꼴 + 칸마다 [✕](낭독 "시작 날짜 지우기" · "끝 날짜 지우기"), 비면 달력 아이콘. 두 날짜는 서로 막지 않고, 거꾸로면 고른 즉시 끝 날짜 칸만 빨갛게 하고 + 두 칸 아래에 "끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요."를 둔다(웹은 두 칸 모두). + - **열고 닫기**: 열면 걸린 조건으로 채운다. [찾기] 없이 닫으면(✕ · 뒤로 · 가림막 · 끌어 내림) 고친 값을 버린다. + 시트의 [조건 지우기]는 칸만 비우고(기간은 전체 기간 · 알림까지) 시트는 열린 채 · 찾지 않는다(웹 2026-11-07 규칙). "최근 N"은 [찾기]를 누를 때 날짜로 바꾼다(웹과 같다 — 자정 무렵에만 다르다). + - **[찾기]**: 날짜가 거꾸로면 넘기지 않고 그 칸으로 스크롤 · 초점. 검색 칸과 글 조건 넷의 낱말이 합쳐 8개를 넘으면 시트를 열어 둔 채 단추 줄 바로 위에 빨간 글 + "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요."(웹 패널은 `VAL_001` 알림). 통과하면 시트를 닫고 검색 칸의 글로 찾는다. + 검색어도 조건도 비면 요청 없이 찾기 전 화면이다(웹은 검색을 닫는다). + - **조건 줄**: 찾을 곳 줄 아래(최소 48) "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 글은 웹 `searchConditionsSummary` 와 같다 + (쉼표로 잇기 · 기간 이름 또는 "2026. 10. 1.부터 11. 5.까지" · "… 하루" · 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일") — 줄 수를 자르지 않고 낱말 단위로 넘긴다. + 줄의 [조건 지우기]는 걸린 조건을 모두 빼고 칸의 글(비었으면 보이던 검색어)로 다시 찾는다. [✕] 뒤처럼 찾기 전이면 조건만 뺀다. + - **결과 글**: 검색어 없이 찾으면 머리는 "N통" · "찾는 중". 결과 없음의 제목은 검색어가 없으면 "조건에 맞는 메일이 없습니다", + 도움말은 검색어 + 조건 "다른 말로 찾거나 조건을 줄여 보세요." · 조건만 "조건을 바꾸거나 줄여 보세요." · 검색어만 "다른 말로 찾아 보세요.". + 자판의 [검색]으로 낱말 8개를 넘기면 1차처럼 결과 자리 안내이고 글은 시트의 글과 같다(1차의 앱 글을 바꿨다). 조건 줄은 남는다. + - **이어지는 곳**: 조건은 화면 상태(`MailSearchState.conditions` — 주소에 싣지 않는다)이고 이어 찾기 · [더 찾기] · 당겨서 새로고침 · [다시 시도] · 찾을 곳 바꾸기 · + [전체 메일함에서 찾기]에 그대로 이어진다. 결과에서 연 메일의 강조 쿼리에 q · from · to · subject · body 를 싣는다. + - **가림막**: 앱 대화상자 가림막 색(`AppColors.backdrop` — rgba(11,54,64,.32))이다. 바닥 시트 테마로 주므로 메일함 옮기기 시트도 같은 색이 됐다. + 날짜 창은 `showDatePicker(barrierColor:)`로 준다(테마의 대화상자 가림막을 바꾸면 확인 창까지 바뀐다). - **구현하며 정한 것**(2026-11-07) - 길게 누르기: InkWell 에 길게 누르기 처리가 없으면 뗄 때 누르기로 읽어 메일이 열린다 — 검색 줄은 GestureDetector 가 받아 두고 아무것도 하지 않는다(진동 없이). 줄의 낭독 동작은 `MessageRow` 바깥 Semantics 에 둔다(목록 줄도 같다). - 같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 둔 채 찾고, 그동안 머리를 "· 찾는 중"으로 둔다. 실패하면 결과를 그대로 두고 알린다. @@ -208,16 +234,21 @@ - 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면). - 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다). - 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16. - - 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어에는 낱말을 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다. + - 앱에서 새로 쓴 글: 찾기 전 안내, 낭독 글 "닫기" · "시작 날짜 지우기" · "끝 날짜 지우기"(2차). 1차의 "검색어에는 낱말을 8개까지 …"는 2차에서 웹 글로 바꿨다. 화면 글은 `MailSearchTexts` 한 곳에 있다. - **알려진 한계** - 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다). - Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다. - 보낸 사람이 본문에 쓴 `` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다. + - 조건 줄의 "나" 견주기는 한글만 모아 견준다(Dart 에 NFC 가 없다) — 내 이름을 풀어 쓴 라틴 글자(e + U+0301)로 넣으면 "나"로 읽지 못하고 "{이름} 님이 보낸 메일"이 된다(웹은 NFC 로 모은다). + - 조건 시트가 한 스크롤 모양일 때(가로 화면에 자판이 올라온 때)는 끌어 내려 닫을 수 없다 — 바깥 스크롤이 손짓을 가져간다. [✕] · 뒤로 가기로 닫는다. - **실기기에서 볼 것**: 두 줄 칸 + 글 자판의 [검색] 키(삼성 · Gboard), 한글 조합 중 [검색], 하드웨어 자판 Enter, 여러 줄 붙여 넣기 · 삼성 둥근 따옴표, 결과를 끌어 자판이 내려가는지, 자판이 열릴 때 탭 막대 · [되돌리기] 알림 자리, 큰 글자에서 전환 칸 · 개수 줄 · 결과 줄 메일함 이름, TalkBack 차례 · 개수 줄 읽기 · 칸 이름 한 번 · 찾을 곳 고름 상태 · [더 찾기] 꺼짐, 노랑 대비 · WebView mark, 큰 메일함의 [더 찾기] 흐름, 길게 누르기가 진동 없이 아무 일도 없는지, 보내기 취소를 기다리는 메일의 "보내는 중"이 풀리는지, 앱 로그인 서버(feat/app-auth) + 14단계 서버로 실제 검색 · 강조 · 준비 상태(앱만 쓰는 직원의 색인이 시작되는지). + (2차) 자판과 함께 시트가 올라가고 초점 칸이 보이게 스크롤되는지 · 가로 화면 + 자판, 기간 메뉴가 뜨는 자리, 날짜 창의 한국어 머리와 연필(입력) 모드에서 오늘 뒤 날짜를 막는지, + 뒤로 가기 · 끌어 닫기 · 가림막에서 고친 값을 버리는지와 닫은 뒤 자판, TalkBack([조건] 펼침 · 기간 단추 · 날짜 칸 값과 오류 · 단추 줄 위 알림 · 닫은 뒤 초점), 큰 글자의 날짜 칸 · 여러 줄 조건 줄, + 바뀐 가림막 색(옮기기 시트 포함), 14단계 서버에서 조건만 · 검색어 + 조건(시간대 · 첨부) · from/to 를 실은 강조. ### 새 메일 알림 (푸시) - 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). ### 처음 판에서 미룬 것 -서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 조건으로 찾기(2차)와 결과에서 여러 통 고르기를 미뤘다. +서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저). diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 82ba39a..e172d14 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -762,13 +762,16 @@ interface MailState { 그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다. 목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게). - **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다. -- **클라이언트 — 앱**(Flutter 1차 — 앱 디자인 README 「검색」, 2026-11-07) - - 검색어 · 찾을 곳을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다. - - 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개)를 먼저 본다. 낱말이 8개를 넘으면 묻지 않고 결과 자리에 "찾을 말이 너무 많습니다"를 보인다(웹 검색 칸은 `VAL_001` 사전 문구 알림). +- **클라이언트 — 앱**(Flutter 1차 · 2차 — 앱 디자인 README 「검색」, 2026-11-07) + - 검색어 · 찾을 곳 · 조건을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다. + - 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개 — 2차부터 검색어와 조건 넷을 합쳐)를 먼저 본다. 넘으면 묻지 않는다 — 자판의 [검색]이면 결과 자리에 "찾을 말이 너무 많습니다", 조건 시트의 [찾기]면 시트 안 단추 줄 위 글이다(웹 검색 칸 · 패널은 `VAL_001` 사전 문구 알림). [✕]는 검색어와 결과를 지운다(웹의 ✕ 는 검색을 닫는다 — 앱은 [←] · 뒤로 가기가 닫는다). + - (2차) 조건은 웹과 같은 이름으로 싣는다 — `from` · `to` · `subject` · `body` · `after` · `before`(기기 시간대의 시작 날짜 0시 · 끝 날짜 다음 날 0시를 UTC 로 — 웹과 같은 순간) · `hasAttachment=true`. + 검색어가 비면 `q` 를 싣지 않는다(조건만으로 찾기). 날짜는 1970-01-01 부터 오늘까지만 고른다. 조건이 걸려 있으면 빈 칸의 [검색]은 조건만으로 찾고, [✕]는 검색어만 지운다(조건은 남는다). + 검색어도 조건도 비면 묻지 않는다(웹은 검색을 닫는다). 조건 칸은 14단계 서버부터 받는다 — 그 전 서버는 400 `VAL_001` 이라 받지 못함 화면이 된다. - 이어 찾기는 웹과 같이 한 차례 5번 · 20통이다. 다만 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 묻는다. - 결과는 새 메일 확인으로 다시 찾지 않는다. 앱의 읽기 화면이 바꾼 것과 붙잡힌 메일의 풀림(보낼 시각 + 2초에 그 줄만 `GET /api/messages/{id}` — 강조 쿼리 없이)만 반영하고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. - - 읽기 화면의 강조 쿼리(`q`)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다. + - 읽기 화면의 강조 쿼리(`q` — 2차부터 걸린 조건의 `from` · `to` · `subject` · `body` 도)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다. - 강조 위치는 NFC 로 다시 맞추지 않고 그 위치를 준 응답의 글(목록은 그 항목의 제목 · `preview.text`, 읽기 화면은 강조 응답의 제목 · 첨부 이름 · textBody)에만 쓴다(Dart 에 NFC 가 없다). 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다 — 글자는 같고 목록이 다시 맞추면 돌아온다. - `GET /api/search-index` 는 찾을 때마다 알림 없이 묻는다 — 이 요청으로 앱만 쓰는 직원의 색인도 시작된다. -- 2.52.0 From 5b96ec2e85f88de80fcc588bb93de973145ede8d Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 22:40:17 +0900 Subject: [PATCH 25/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EC=83=88=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=95=8C=EB=A6=BC=20=EB=B3=B4=EB=93=9C(App1Push)?= =?UTF-8?q?=EB=A5=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=EC=97=90=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 사용자 확정(캔버스 v65). 허락 묻기 · 설정의 새 메일 알림 줄 상태 · 휴대폰 알림 모습 · 로그인이 끝났을 때 알림 · 업데이트 화면 · 앱 아이콘 후보를 그렸다. 앱 아이콘은 남색 바탕에 흰 ARA 마크, 작은 아이콘은 ARA 마크(실기기에서 다시 본다). Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/App1Push.dc.html | 835 +++++++++++++++++++++++++++ docs/design/webmail/canvas.json | 8 +- 2 files changed, 840 insertions(+), 3 deletions(-) create mode 100644 docs/design/webmail/App1Push.dc.html diff --git a/docs/design/webmail/App1Push.dc.html b/docs/design/webmail/App1Push.dc.html new file mode 100644 index 0000000..d42368d --- /dev/null +++ b/docs/design/webmail/App1Push.dc.html @@ -0,0 +1,835 @@ + + + + +앱 · 새 메일 알림 · 업데이트 · 앱 아이콘 + + + + + + + +
+ +
+
+

허락 묻기와 설정

+

앱 화면입니다. 받은편지함에서 한 번 권하고, 휴대폰이 묻는 창은 [알림 켜기]를 누를 때만 띄웁니다. 안드로이드 12 이하는 묻지 않고 처음부터 켜져 있습니다.

+
+
+
+
+
+ + + +
+
+
+
+ +
+
+

1. 받은편지함 · 권하기 줄

+

안드로이드 13 이상에서 아직 묻지 않았을 때만 보입니다. [✕]로 닫으면 다시 보이지 않습니다.

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
+ +
+

새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.

+ +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ 메일 쓰기 + +
+
+ +
+
+

2. 휴대폰이 묻는 창

+

[알림 켜기]를 누르면 안드로이드가 띄우는 창입니다. 모양과 글은 휴대폰마다 다릅니다.

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
+ +
+

새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.

+ +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ 메일 쓰기 + + +
+ +

ARA SYSTEM에서 알림을 보내도록 허용하시겠습니까?

+
+ + +
+
+
+
+ +
+
+

{{ afterTitle }}

+

{{ afterNote }}

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ +
+ 메일 쓰기 +
+ {{ afterText }} + +
+
+
+
+ +
+
+

4. 설정 › 새 메일 알림 줄

+

설정 탭의 다른 칸과 로그아웃 확인 창은 App1Shell 그대로입니다. 알림 줄만 상태마다 그렸습니다. 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태입니다.

+
+
+
+ +
+

{{ c.name }} · {{ c.when }}

+
+
+
+ 새 메일 알림 + {{ c.desc }} + + + + + +
+ + {{ c.busyText }} + + + +
+
+
+
+
+

설정을 열지 못했을 때 · [설정 열기]를 누른 뒤 뜨는 알림

+
휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.
+
+
+
+ +
+

{{ c.name }} · {{ c.when }}

+
+
+
+ 새 메일 알림 + {{ c.desc }} + + + + + +
+ + {{ c.busyText }} + + + +
+
+
+
+
+
+
+ +
+
+ +
+
+

휴대폰에 오는 알림

+

안드로이드가 그리는 모습입니다(Pixel 같은 휴대폰). 알림 글은 ‘새 메일 N통’뿐이고, 앱을 보고 있을 때는 띄우지 않습니다. 왼쪽 위 단추로 작은 아이콘을 바꿔 볼 수 있습니다.

+
+
+
+ + + +
+
+
+
+
+
+
+ +
+
+

5. 다른 앱을 쓰는 중

+

화면 위에 잠깐 떴다가 상태 표시줄에 작은 아이콘으로 남습니다. 소리와 진동은 휴대폰 설정을 따릅니다.

+
+
+ + + +
+
+ +
+
+

6. 알림창

+

새 메일이 더 오면 이 알림의 수만 바뀌고 쌓이지 않습니다. 누르면 메일 탭의 받은편지함이 열립니다.

+
+
+ + +
+
+ +
+
+

7. 잠금 화면

+

잠금 화면에도 수만 보입니다. 보낸 사람과 제목은 알림에 싣지 않습니다.

+
+
+ + + + + +
+
+ +
+
+

8. 로그인이 저절로 끝났을 때

+

앱을 14일 동안 열지 않았거나 로그인한 지 30일이 지나면 한 번 옵니다. ‘새 메일 N통’ 자리를 바꿔 씁니다.

+
+
+ + +
+
+ +
+
+

9. 작은 아이콘 후보

+

상태 표시줄과 알림 앞의 동그라미에 쓰는 아이콘입니다. 휴대폰은 모양만 쓰고 색은 직접 칠합니다.

+
+
+ +
+

{{ k.title }}

+
+
+ +
24dp 칸을
4배로
+
+
+ + + 상태 표시줄, 실제 크기 (옆은 다른 앱 아이콘) +
+
+ +

{{ k.note }}

+
+
+
+
+ +
+
+ +
+
+

업데이트 화면과 앱 아이콘

+

앱 아이콘은 아직 Flutter 기본이라 이번에 함께 정합니다. 삼성 휴대폰은 알림창에도 앱 아이콘을 씁니다.

+
+
+
+ + + +
+
+
+ + + +
+
+
+ +
+
+

10. 업데이트 화면

+

최소 버전보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 옵니다. 뒤로 가기는 앱을 닫습니다.

+
+
+
+
+ ARA SYSTEM +
+
+
+

앱을 업데이트해 주세요

+

지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.

+
+ +

지금 쓰는 버전 1.0.0

+

Play 스토어에 업데이트가 보이지 않나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+ +
Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.
+
+
+
+ +
+
+

11. 앱 아이콘 후보

+

안드로이드 적응형 아이콘입니다. 휴대폰마다 동그라미나 둥근 네모로 잘라 씁니다. 마크는 잘리지 않는 가운데 칸 안에 둡니다.

+
+
+
+ +
+
+

{{ a.title }}

+

{{ a.sub }}

+
+
+
+ +
동그라미 (Pixel 등)
+
+
+ +
둥근 네모 (삼성 등)
+
+
+
+ + 홈 화면 · 밝은 바탕화면과 어두운 바탕화면 +
+
+ + 삼성 휴대폰 알림창 (실기기에서 확인) +
+

{{ a.note }}

+
+
+
+
+ +

안드로이드 13 이상에서 휴대폰의 테마 아이콘을 켜면 셋 모두 바탕을 버리고 마크만 휴대폰 색으로 칠합니다. 그래서 마크만 담은 그림을 한 장 더 넣습니다.

+
+
+
+ +
+
+

12. [설정 열기]가 여는 휴대폰 설정

+

안드로이드가 그려 모양은 휴대폰마다 다릅니다. ‘새 메일’이라는 이름과 설명만 앱이 정합니다.

+
+
+ + +
+
+ +
+
+ +
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index 683c2b4..147100d 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -57,9 +57,10 @@ "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true }, "App1Search.dc.html": { "x": 0, "y": 33450, "w": 3098, "h": 1028, "title": "앱 · 검색 (찾기 · 결과)", "is_interactive": true }, "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true }, - "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true } + "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true }, + "App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -76,7 +77,8 @@ "phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 }, "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 }, "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 }, - "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 } + "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 }, + "app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 2222 } }, "designSystems": [] } -- 2.52.0 From bf725ae96d6ad78d4d911461ddf58103b1dbcb9c Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 00:45:36 +0900 Subject: [PATCH 26/32] =?UTF-8?q?feat(flutter):=20=EC=95=B1=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EA=B3=BC=20=EC=95=8C=EB=A6=BC=20=EC=9E=91?= =?UTF-8?q?=EC=9D=80=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=84=20ARA=20?= =?UTF-8?q?=EB=A7=88=ED=81=AC=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확정 보드 App1Push(캔버스 v65)의 ⑪ "나. 남색 바탕"과 ⑨ 작은 아이콘이다. 앱 아이콘은 #0B3640 바탕에 흰 ARA 마크(적응형 · 안드로이드 13 테마 아이콘 monochrome · 7.x 용 PNG), 알림 작은 아이콘 ic_stat_ara 는 흰 실루엣이다. 그림은 브랜드 원본에서 저장소 밖 스크립트로 만들었다. Flutter 기본 아이콘을 이것으로 바꾼다. Co-Authored-By: Claude Opus 5.5 --- .../android/app/src/main/AndroidManifest.xml | 1 + .../main/res/drawable-hdpi/ic_stat_ara.png | Bin 0 -> 739 bytes .../main/res/drawable-mdpi/ic_stat_ara.png | Bin 0 -> 461 bytes .../main/res/drawable-xhdpi/ic_stat_ara.png | Bin 0 -> 1100 bytes .../main/res/drawable-xxhdpi/ic_stat_ara.png | Bin 0 -> 1941 bytes .../main/res/drawable-xxxhdpi/ic_stat_ara.png | Bin 0 -> 2703 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 13 +++++++++++++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 13 +++++++++++++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 3799 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 2214 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4498 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 2423 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1295 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2837 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 5273 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 3225 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6328 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 8618 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 5442 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10178 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 11860 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 7770 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 13995 bytes .../app/src/main/res/values/colors.xml | 5 +++++ 24 files changed, 32 insertions(+) create mode 100644 flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png create mode 100644 flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png create mode 100644 flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png create mode 100644 flutter/android/app/src/main/res/drawable-xxhdpi/ic_stat_ara.png create mode 100644 flutter/android/app/src/main/res/drawable-xxxhdpi/ic_stat_ara.png create mode 100644 flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 flutter/android/app/src/main/res/values/colors.xml diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 1030971..ffb7c35 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:label="ARA SYSTEM" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules"> diff --git a/flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png new file mode 100644 index 0000000000000000000000000000000000000000..add14e9b73c31e6a117c55144f1311678ebe31a0 GIT binary patch literal 739 zcmV<90v!E`P)@N+Moh;hJ zHdfCW{{rj=7CIn+bJkhpeG_;Cyb5|X;8NH({9T?k5HL0d(jC^bY^>#g8g_CWm}{A7 zArohT9^jC5TF%N;^E#n!xl_O#+uj}n+5+4KZUEJsa?{_FSoJSSFd-(fOd8WqUAJyFl`W+YWbPLd4_MfVyyrbuQ!?3>X5B0xIGF(F1xb2 zq=wt~LP}|gq+1?>V5A*A%X*ppXObRxUk)|-A3i^F&jQ6~M|_5iXlO__jr64Xh1}mgM+y`uP@up+!go84 VZ?UzE__zQ7002ovPDHLkV1j=^OU?iQ literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png new file mode 100644 index 0000000000000000000000000000000000000000..fc77e41c7b925900f4e2b96ff4f56588914a2b70 GIT binary patch literal 461 zcmV;;0W$uHP)xy!djvUXgI}e&O(*?{m&`&iC*>&jUT2PpGM#7uG3$+`g^}YDMkq zmfI;lhHO0T2FOW+#t6nGBwrRkAH>TpTra)`;)7tvrL_@X)=@uvDfO%>n}*aR*BeZhZ+Uk6?R zSA+iow!)uP-val7Zv)FfHS9JpPz45nabP9>^_4h=D?kPe0UyJ^1Uv?&fs4QxumwkR%IrF?SBvD04i^1hT(-h0`5_<#HbFVy!XeN+*+00000NkvXXu0mjf DDlFUv literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png new file mode 100644 index 0000000000000000000000000000000000000000..232f70a2265bc869e0ebf7c800c1ea98fc3cd3f2 GIT binary patch literal 1100 zcmV-S1he~zP)!HVp93QGJ`~-sEn{N%n7@r_x^h5zlY`F-1!iuUd|s5_nx)aT6?Xv*IH+7 zsX~Pc6)IGyP@zJF{|jk4Oh8RiEv0m78hoilZ%Yy>C8lc_&>^XnQfkd=1c2ntro&qY z>i^jZc(azI8zl8hdN8Fl4rIAnN~sz6mq@x;(qc(Hk{XhZNcut2!IaWicDg8==_w_E zZb^fZ-jMW38LwuLIs!iAU)<=3cLkosz+=DxphlfaLjz0fD1w`)qzF8pJC7YqCGP$&IEi7i~@&k z#P=cMD+)5Zz4oFV>akImI=xyzKd=CJ)x0NwM{-&Mt4;sW{KJKesFj|EH>~^VGUJt2 z_BOEH3R}Q>;H=n%{JX2*z+CfO;nV_H752Qvh63yZZgLPtfjdlZ02f+cXEDX6I)y=R z+z3Z{EEwMz^D{V5Orasuj{*xFoGjZ5yd3nmfUREp+_bep*EPU%K?qKvur_UZo zC>Lp`KUe^d#~xmAU?3RyfoY?_dDc@aY1bRfx3u8k6Q&=^O`)`9`nS*3|DJ^&2u9|O zp7h$@{3R`M;04obHaZ7pxX`uoGtbT8yC65mYfC~cE^`n&+J1RM)EZ8M6h1P^U7+db zsyteF)6cxop!s{O@2-%)&xh3M_WwJ*w$Aj9SRcU0UVq8-uE|>GwPt>Q5}sX7vke9M z-Xc91GZdUySCAX==x}bVlOTJK^&STL+RZ!*J;US=&bZ&N4!oBG?}j4<_|mh>_ClxF z=E&#Tnd>6VH~_o@91OI59t>0HaL*LvjstJzdgzcuxO5zqv`NxQNp(r{!+J(j(q>8d zax+g-r^n`@z`sC}JRU2h^cT=CX^nNvmo!Jx7ap0B)Ei{-Xq_bOm$cJ*yGv}Eeav{8 zJd+Den~ATPuYxvWCbRizSHgTXoyyn3C8=I^@tW#-TcJXQ3Kc3;s8FH8?BgHE-!F=* S?rFyW0000jXmY>FLp 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 q?=ee6v + + + + + + diff --git a/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..dd6e294 --- /dev/null +++ b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..f03f8d428b291b8cfe463be655a976d7260ffc63 100644 GIT binary patch literal 3799 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 N002ovPDHLkV1nP5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..df75a0853033228b26d46bbbae8dc42b5040ce99 GIT binary patch literal 2214 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 literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uyx#ZT4Qo3O%a`Yec!}EFmgZH070ecZTW;y@>ApCs2gIE64&%vN8 zy?8uK0RURPe%`wX^!};h$nz>9s%ts5X1flCck3z5+tTGlyMw+V`f#7N)n7U7c9fku z*z1gf42FTNH7qYqGZc_MAPb+J4T1k%-*AnV4#VF@8 zcTAKpJsMi2nuyd=fh~pO+-3TU;njhj%-{wp7fT+e1Y%;~u;P4oUa(beWeb5wwC58V zbQdK@{stP@R~TL(#TohabdBY=at1Tolw$nkq>+yU0UW0Voh>G$ae`#0$4P= zCp-}IM2b0BS%2ceE4^7p{HW!S%w2K2RbCr-28R_pV< z@Iiiy-Di1X#^z@ohCcZb3Kv1-vc~X0=>A+ zgkI?zv5uRQ#;?z5=S`Rt^EN%f5{8x+ z%kJ^QPuf0TuyUw|eKDkUr@DT6`E?L?nfWpQb?=D`RT=awJgO#1H*<92s!g-j`}Ide zOxd1}5@|$ZC+SvJ{hGd>&!^(6=*IXrs1f#LNF?OucTg8!QqOqaB4yUgkET5cfu3sJd-fF!X^n-uiX97~%O_Xx#mC0|-u$iMpCv-G4Tv z#I(ukaoskKyF__~XQJG2W1PXhFqZ1ccTNK;aE!!4L{iQab#%57BGL7bY7ZtB()9DR{7*XTN9PDyZ^seof~uhH_VaW+ZF3Q&)$ zv#>gC5sCfPM==eSFx92nmdmrCsBr4*EgLNW$E*)nw-$*)i8JjOOcOmj`3Z!{`X=c2 zkL|p|`z}R#N0K;0f~|9-32~&8xwRo?On1EbU94z~B%9hvJuv`{g5cM~?`wyH2LU`< zo(z_}JFD8DUm|kveC#uFoGj=s!@dkAAII|`d2A{ryXs3IttwV1o{HkD{y7wO@cU)1 z{p#%Z1#SAE9RgA*zU)v>w1;$(#Y%YCsx`lE{@~QA7t3Vl_%Pqx{&dIFo{y&g%Nu=^ zz1@~TSuml}+{b4;ith-8MB*MIF(<@-Z@cZsprz9L4tGdDe!TjoCk23^(w literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a01074c7bccd6e17777a13c2089a264b140511c4 GIT binary patch literal 2837 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 literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391482be68e9e4a07fab769b5de337d16eb1..7b39e97d21f4eccfb0f83938609927f96fc202ec 100644 GIT binary patch literal 5273 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 diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcac3c1f120f9799c2b292ac096c6771424df5d GIT binary patch literal 3225 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 QXD|RN3YzjYvhP3r7i^H{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..de22366bff2e500cea6f50543f15e4a0130daf74 GIT binary patch literal 5442 zcmd^D`8N~{7Z!?&B$cgfSt5q9W}So>OBh1-lI&z1j9rVYHI1>;Ys-=?8M`4SvWyzC z&1hm`-oX&Vh%w{q{X4#U&i&y&=Q-y-_uO-ybI*NgbJvvjjK~=_Ha1?cnK6Wo?S%1v z$$jcC;<0A>nT_qzX|S=Oee?o#5%#L`Ud-?|ky5bu%;?DSl2MyFwx2 z!ljGN-2AsszPzw~8{!La0Q{(_v8^bVd}(`LW=Lo|CH=%jX(5Y&npg1KCygD>J3X5B z?q=kH4z*)%#t{3)cgNu+Tkq-u0E@IJ^gD9=p=;T-6Mt3w|Hkj*JLTLcMbw+hyW8~% z=3fxwDS54S=JDpwlepDSX6(?@x=oMxbco!)BqSah7@ENyEEcRGj=1-gTNYgw@XHGQ z7u%{XYT#IiY+Zrj-HNK}0NpA?AdCylWwvIbeJoKG0qZL}He;94+k{dsS;i6S`{9-RG_ zm=X3vnv#UTHE^1E(oPM{|B0>R(!|UL$LV9DMstcRB(D4W96)ngA#h%IdoO+M_Jjw>(IB`q z`S(0ZIgmT0SE^>uncW4ww2BXmG0{42J0QM#5x!W-Z!s8LbO3I#U zT!ZmJA@5U6Su9C>PMP#|+y5J!u~*bPu+;UCm!mEbAY?6V_~I5&w#gD33Lj) zPm(eSKllA@$OpkxsRJh_@UVgb*EDnXAjED%TSM0`4m%hn?pqRE^)|1A^UM#e@p6}% zdev6mB2qU(EMhGY0WsK4=+1SRY+HdG3db zq0Po+viqsp$$5j1V$p>@Dg<6l}oE zFo|IvbOdow7h|c2C8G8IOiL6zi_1mI&`?^B{|IbeuD&i z^Q*SO4jYfT^EVHTQkIu6!0apu%5yISk?6;8#p^o6hkJYKKkhrxvL;X>d?9HE=pmdK zj-VdbBuuJA2mn^f7e4IzN@jhFM@zD~vx$*Q*D~m+1?b z^^W0Zru8pt=0P*w-kWvMO$s9PQVCxGI#G^E#RSx8H-;;aKH zB&wN-T?PgXu;fr4z>TByupgRYTXj8R!8J0gWr@gu>A#D|2wQ5mp3ymY7-s~GiJty9 zkjb140{PfWxc*GaPsRV>k07Wn9hHvg&=MWK4@H!&J7ZgTjvVI=-q#AdMOa)Rs+0%e z6CswD*0l6yztHZhp7#9UC*ct!WZ5xDpTSHQ1dr`>cFA`Fs(Tb~Fu3JvCX|eC5fgC+N2+) zhm!J(>V{F%VKlA*LDQ2(rNC=}*lG)*7r6cz` zS;+H)cIa3jk)UGJJVO_}vwL-QnCK5{D%ZpnkbU=WXR00>Stw~89}RomU7=jGTKg-s ztLnK^&Yi?@Ixi+I(x4q*-dP*~7gX=T#uW#AZjf}CVCsiAloU#-=!p|wds&_r(}?-w zgMx~*H$7=#f5?BH{*pDAVBt{kBCx4IC3P~8Ut&@T$sO$P3*UcX=@x5!#j8FVRJs_p z*mp z>9}k9$7jIeuRkiNE~tCw9tRixwq zt6Xzv+-^`VU##%cQ{Y-^c77O)b<-!>em(7<_HIbK;krZl_zYRd3Zu;X<@!{Pc5*#= zep)$iyI0b`Sga*MXZDYQpk1iLrD2$hQLAXPWhT^?kXk3 zeENlIze77P!=t~c+UlZLjYLWl$s-pc;d@<8XIV-# zIC1eSVG5z83M-_xnW!Z^JEgl#6(vVMt)$jfKb}!yIa*uE$3GD1=3cFi+Ngx4R#pU| z&2eSmg%)oQze`r@z0$o3!O)LA$}z1?cBG^I*Spf-NyKhubm@nHYl$jMX}K=%K?TKU z_HN!H_1zV#P{)>wO+kazs3BhOthM;>horO5)ycv6lf~^o|Fk#%YVri{rpx|1E2z-m zFn^T2u>2g@B@=sX=h}?MPL>jx5Kv>An0ew7u;v3NK|~{y*S5lmzwN1;3{}yusQfan z0k=1Kt~PnxtP6tUm#+eu$v0%Nx}+&yyjxJYY=$)0`XyCEaA$dacmlJ(r;W8I&%VT? z&2^-!c0+8{HtzO{=+-=ol$tXD{>Tdp;M58XvFVc&tEleL%f4S0J{9@r8tRO!1mGkn zJHzvSsN{DojMrn)l&5vo;ikBK>5^iIjzU$0)$6UTC81o_=quZ=(v%^Su0MyEksvfT z_n!7Dqfd%bAYVm-n73P26zHw(nKbLikCXzeDke!tw}XKhdf#e4Of>JziO`H-4Z#yF zJxP$kpex!ct`{_V&eFIe&1>@@Vbh0??aW0hlx=yZ%%mw8l;hAx#K)xLfL7ji^~yVK zs#Ot@V!3W3p81Tmz^$wCusY4&E=$}zhReCHNo(L^zWVZUg{U|}&E(djQQp6u&ZR*w zO}OT4YxN=rvaje7b0G_!uo%vPgfUt|&w_qqv12=w$6s;i*f>F79I@LCPq4=M!QBBA zMKIqz4U+2ltAUI~+W?D<4%kwSi@X()}+4lxpA zug~iD+l(I{IShIZm!R5fA*l_`@x|0cWjH9a+sm`*f%7*G6v$q7o(*~Xmg{BO>>Z`1 z_2J?kn%8uPhm3w36`lV?+jUA7e6ZX*M)#5}+1Fee+L(9RM9Mu<&bbw_lJ;`0-55S| z@(Y_?@u1|rf?u<&WChAl;NCtAgH0_{ptlPTAI`P?&e`b-yMt`GIK@g-1*BzNn-M)$ zkNVWzCFv4AOHU?Q)``JY-@A8)5h_nxkz0|S(QXW5-df#9qEm^^{qkA|93(eh;irKN zVczvDyD6RD+=NO0FQojO{ero(K4s4@Sw?t0pjcwF&r;qh@ZN)_tkQ<=+t>Z1wIO+M z@%TJIl);dcPzg{vku)S6zG@ThasLq-Kn5j1Gv6G6LHow!o3lRPSl5;Xxx51mutrU7Bi>3rvVss+wu#~8FjTYM zMEfz4qFv=!$N>SxgZHyzUc@YGK~YeaGxe43PiRRcx+xmln{Ir{ijh=o09FbVp`gZyGo>=C@+u9}D4m%5(tz)XR8Lzail?#m0OE94aDgv z7H)Z5Hn~zR$!Lgs6U3ghN>M36I#1!9akFSoKzHY*ipqDq!a+ZL8K(sv#gsay%$0+- zE0ij*E8E#+AvTJoTl+4f2OqWXnuxTWyj^PYM5SW1|M$;78CQ}q#T`2F%+b!uXLbI@ z>wnD*TQDd#I>nt91NcctB~Ghq$R7unQ01_4Z!c=B){DR`2)eUzPffH#>?{ZU%zyrl zDx7#|bJBk;AF60I#v%X0{}rp=BXY+B4sjOe;4j_Pet4ST4E9hgt#@~hy6rmMivgqx zMRYl)UQ((Ra;@_qq2MeG9hI7|8v1qAVz0EuaCM`nQKyXN12Ko1#!R#oCjux_B7Kj- zIxEGjeAuO&o8Em6V&CCjc}8<36{hpt+*_a=T|vuI-kOXgO-1bVa??E!5v7HuCAuP+ zpT)0JDuB0jDH0YeBB&)c`-qWP3VGGlAv0y;kxEFjCOdO36lKb*K05yR&zocPj#stH zt7f;?C0(`iM>9ajfnTz;@OK^S?wbZqj~VM;yK<|BNN)ioE#2v)9n^vVS1X#lX8lOv z@rAfVv(^LlL;hOH^83N1C6$IV8}7LQTkwv=#|9HPU+khX<&3^+_}v3gXxQ#O`Y_L# zG1F<+_R?m`1_D=IBM+Jj+nBGB?^~d++nEuHD|$tc zSf|v3+{jjK^o!wn_p1{Lji&5U8lN5x&28^#+|xOX+m6%N644?e{W!BGwJNJd3m(&7 zMLbqF&EqSO!o6IW@G|8bqLM9R8%L`fH!%SnJxj4;@gMQv*mwj@wR)K?rzh!$s|G&@Pta96wWw^%8|3!g!Id&%AS{d8Q2m=Q7AVeN?@&lT_XR{0m+zv# zDg6a%G`$ZAMYPBl0SCun=S%A&$JO3Ohwd$M z69%aEnFv&|{&j~2o*QId6ww!k_Ul3C6SIBi^iFJt@m|PoOv@s@pw{FCy^45O$yNSk z4{p{33WI9m#gQL9c6+H3epbK_1SHnrnDqeMZgE(f&}sD3L1crg^>NBnf(_0YypI85 zGXjYX!Y#CT6yKDmOLCZ6jK-r2lWshqDT%?o!{`QG-)8riD@Ds8TNzSj1=hZNX^^AulaNx8{EKW{!GA!v=?+LmGr$VT#2-5n6NOxKg_Hb#UUz=rxW zMM4HW?yN0O_HB~%Y0z2X({#S!d?ix4V_DuO7 Dw)YPq literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..48b85c716df64c9523477d524a3cf35bcc7b9247 GIT binary patch literal 10178 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 literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372eebdb28e45604e46eeda8dd24651419bc0..86c3114bcb821062bc831d000ad21f01ce1ed385 100644 GIT binary patch literal 11860 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 literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..37de2e93e40afad30a4ae59a50286380359510f2 GIT binary patch literal 7770 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 literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/values/colors.xml b/flutter/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a8f0bf8 --- /dev/null +++ b/flutter/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #0B3640 + -- 2.52.0 From e03eda1fd347df886ff22bdb2d1c7a618c9b0c89 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 00:46:11 +0900 Subject: [PATCH 27/32] =?UTF-8?q?feat(flutter):=20=EC=83=88=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=95=8C=EB=A6=BC(FCM)=20=C2=B7=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=ED=8C=90=20=ED=99=95=EC=9D=B8=20=C2=B7=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다. - firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다. google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다). - 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다. - 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다 (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다). - 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다. - 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다). - 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다. - 시험 932 → 1,056. Co-Authored-By: Claude Opus 5.5 --- flutter/android/.gitignore | 4 + flutter/android/app/build.gradle.kts | 19 + .../android/app/src/main/AndroidManifest.xml | 28 +- .../kr/it/arasystem/app/MainActivity.kt | 7 + .../it/arasystem/app/NewMailNotifications.kt | 126 +++ .../app/src/main/res/values/colors.xml | 2 + .../app/src/main/res/values/strings.xml | 11 + flutter/android/settings.gradle.kts | 3 + flutter/lib/core/api/api_paths.dart | 15 +- flutter/lib/core/api/dio_client.dart | 10 +- flutter/lib/core/api/dio_provider.dart | 6 + flutter/lib/core/api/user_agent.dart | 64 +- flutter/lib/core/app_config/app_config.dart | 116 +++ .../app_config/app_config_controller.dart | 138 ++++ .../app_config/app_config_repository.dart | 32 + .../lib/core/constants/app_info_provider.dart | 6 + flutter/lib/core/constants/screen_titles.dart | 3 + .../core/push/firebase_push_messaging.dart | 123 +++ .../lib/core/push/notification_system.dart | 127 +++ flutter/lib/core/push/push_controller.dart | 678 ++++++++++++++++ flutter/lib/core/push/push_events.dart | 60 ++ flutter/lib/core/push/push_messaging.dart | 142 ++++ flutter/lib/core/push/push_preferences.dart | 144 ++++ flutter/lib/core/push/push_registration.dart | 92 ++- flutter/lib/core/push/push_state.dart | 250 ++++++ flutter/lib/core/push/push_target.dart | 5 +- flutter/lib/core/push/push_texts.dart | 47 ++ flutter/lib/core/router/app_router.dart | 41 +- flutter/lib/core/router/app_routes.dart | 3 + flutter/lib/core/router/push_navigation.dart | 46 ++ flutter/lib/core/router/session_redirect.dart | 5 + flutter/lib/core/theme/app_colors.dart | 7 + flutter/lib/core/theme/app_spacing.dart | 20 + flutter/lib/core/theme/app_theme.dart | 32 + .../app_update/data/app_store_launcher.dart | 52 ++ .../presentation/app_update_texts.dart | 22 + .../presentation/pages/app_update_page.dart | 121 +++ .../providers/session_provider.dart | 18 +- .../presentation/widgets/login_frame.dart | 63 +- .../presentation/pages/mail_list_page.dart | 45 +- .../pages/message_compose_page.dart | 21 +- .../providers/compose_controller.dart | 83 +- .../providers/mail_state_watcher.dart | 42 +- .../presentation/widgets/push_offer_line.dart | 152 ++++ .../presentation/pages/settings_page.dart | 441 +++++++++++ .../settings/presentation/settings_texts.dart | 30 +- .../widgets/settings_notification_row.dart | 271 +++++++ .../features/settings/settings_routes.dart | 110 +-- flutter/lib/main.dart | 19 +- .../Flutter/GeneratedPluginRegistrant.swift | 4 + flutter/pubspec.lock | 56 ++ flutter/pubspec.yaml | 6 + flutter/test/app_test.dart | 34 +- .../test/core/app_config/app_config_test.dart | 372 +++++++++ flutter/test/core/push/push_app_test.dart | 349 ++++++++ .../test/core/push/push_controller_test.dart | 744 ++++++++++++++++++ flutter/test/core/push/push_mapping_test.dart | 181 +++++ .../push_registration_repository_test.dart | 110 +++ flutter/test/core/push/push_state_test.dart | 149 ++++ .../core/router/session_redirect_test.dart | 68 +- .../app_update/app_update_page_test.dart | 139 ++++ .../test/features/auth/login_pages_test.dart | 16 + .../features/auth/session_provider_test.dart | 72 ++ .../features/mail/mail_list_page_test.dart | 15 +- .../features/mail/push_offer_line_test.dart | 184 +++++ .../features/settings/settings_page_test.dart | 460 +++++++++++ flutter/test/support/fakes.dart | 4 + flutter/test/support/login_fakes.dart | 2 + flutter/test/support/mail_fakes.dart | 8 +- flutter/test/support/provider_harness.dart | 5 +- flutter/test/support/push_fakes.dart | 339 ++++++++ flutter/test/support/push_screen_harness.dart | 73 ++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 74 files changed, 7067 insertions(+), 229 deletions(-) create mode 100644 flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt create mode 100644 flutter/android/app/src/main/res/values/strings.xml create mode 100644 flutter/lib/core/app_config/app_config.dart create mode 100644 flutter/lib/core/app_config/app_config_controller.dart create mode 100644 flutter/lib/core/app_config/app_config_repository.dart create mode 100644 flutter/lib/core/push/firebase_push_messaging.dart create mode 100644 flutter/lib/core/push/notification_system.dart create mode 100644 flutter/lib/core/push/push_controller.dart create mode 100644 flutter/lib/core/push/push_events.dart create mode 100644 flutter/lib/core/push/push_messaging.dart create mode 100644 flutter/lib/core/push/push_preferences.dart create mode 100644 flutter/lib/core/push/push_state.dart create mode 100644 flutter/lib/core/push/push_texts.dart create mode 100644 flutter/lib/core/router/push_navigation.dart create mode 100644 flutter/lib/features/app_update/data/app_store_launcher.dart create mode 100644 flutter/lib/features/app_update/presentation/app_update_texts.dart create mode 100644 flutter/lib/features/app_update/presentation/pages/app_update_page.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/push_offer_line.dart create mode 100644 flutter/lib/features/settings/presentation/pages/settings_page.dart create mode 100644 flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart create mode 100644 flutter/test/core/app_config/app_config_test.dart create mode 100644 flutter/test/core/push/push_app_test.dart create mode 100644 flutter/test/core/push/push_controller_test.dart create mode 100644 flutter/test/core/push/push_mapping_test.dart create mode 100644 flutter/test/core/push/push_registration_repository_test.dart create mode 100644 flutter/test/core/push/push_state_test.dart create mode 100644 flutter/test/features/app_update/app_update_page_test.dart create mode 100644 flutter/test/features/mail/push_offer_line_test.dart create mode 100644 flutter/test/features/settings/settings_page_test.dart create mode 100644 flutter/test/support/push_fakes.dart create mode 100644 flutter/test/support/push_screen_harness.dart diff --git a/flutter/android/.gitignore b/flutter/android/.gitignore index be3943c..69110b0 100644 --- a/flutter/android/.gitignore +++ b/flutter/android/.gitignore @@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다). +# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림") +app/google-services.json diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 73e74a2..14eed18 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -42,3 +42,22 @@ android { flutter { source = "../.." } + +// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림"). +// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다. +// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게. +// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build, +// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. +// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다. +val firebaseConfig = file("google-services.json") +if (firebaseConfig.exists()) { + apply(plugin = "com.google.gms.google-services") +} else { + gradle.taskGraph.whenReady { + if (allTasks.any { it.project == project && it.name.contains("Release") }) { + throw GradleException( + "android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").", + ) + } + } +} diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index ffb7c35..9c1ef3c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,14 @@ - + + + + + + + + + + when (call.method) { diff --git a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt new file mode 100644 index 0000000..f0e191f --- /dev/null +++ b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt @@ -0,0 +1,126 @@ +package kr.it.arasystem.app + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +/** + * 새 메일 알림의 채널과 알림창 — 채널 `kr.it.arasystem.app/notifications`. + * + * **짝: Dart `core/push/notification_system.dart` 의 `PlatformNotificationSystem`** — 채널 이름 · 부르는 이름 · 답의 칸과 값(한쪽을 고치면 둘 다). + * + * - 알림 채널 `new_mail`(짝: 서버 `channel_id` · 매니페스트의 기본 채널 — 값은 `R.string.new_mail_channel_id`)을 앱을 열 때마다 만든다. + * 중요도 HIGH(소리 · 머리 알림) — 한 번 만든 채널의 중요도는 앱이 바꾸지 못한다(직원이 바꾼다). 이름 · 설명은 다시 만들 때 바뀐다. + * 알림은 앱이 뒤에 있거나 꺼져 있을 때 FCM 이 이 채널로 띄운다(앱 코드는 돌지 않는다). + * - `status` → `{appEnabled, channelEnabled, sdkInt}` — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다. + * - `openSettings` → 새 메일 채널만 꺼졌으면 그 채널 설정, 아니면 이 앱의 알림 설정(24 · 25 는 앱 정보). 답은 opened · failed. + * - `clearNewMail` → 알림창의 "새 메일 N통"(tag `new-mail` — 짝: 서버 tag. FCM 은 tag 를 주면 id 0 으로 띄운다)만 지운다. + * + * 주소 · 토큰 · FID 는 다루지도 로그에 남기지도 않는다. + */ +class NewMailNotifications(private val activity: Activity) { + private val manager = NotificationManagerCompat.from(activity) + + private val channelId: String + get() = activity.getString(R.string.new_mail_channel_id) + + /** 채널을 만든다 — 이미 있으면 이름 · 설명만 바뀐다(채널이 없는 판은 아무것도 하지 않는다) */ + fun createChannel() { + val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH) + .setName(activity.getString(R.string.new_mail_channel_name)) + .setDescription(activity.getString(R.string.new_mail_channel_description)) + .build() + manager.createNotificationChannel(channel) + } + + fun register(messenger: BinaryMessenger) { + MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + STATUS_METHOD -> result.success(status()) + OPEN_SETTINGS_METHOD -> result.success(openSettings()) + CLEAR_NEW_MAIL_METHOD -> { + clearNewMail() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun status(): Map = mapOf( + KEY_APP_ENABLED to manager.areNotificationsEnabled(), + KEY_CHANNEL_ENABLED to isChannelEnabled(), + KEY_SDK_INT to Build.VERSION.SDK_INT, + ) + + /** 새 메일 채널이 켜져 있다 — 채널이 없는 판(24 · 25)이거나 채널을 아직 만들지 않았으면 참 */ + private fun isChannelEnabled(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + val channel = manager.getNotificationChannelCompat(channelId) ?: return true + return channel.importance != NotificationManagerCompat.IMPORTANCE_NONE + } + + /** 휴대폰 설정을 연다 — 가장 알맞은 화면부터, 열리지 않으면 다음 화면 */ + private fun openSettings(): String { + val packageName = activity.packageName + val intents = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (manager.areNotificationsEnabled() && !isChannelEnabled()) { + add( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId), + ) + } + add( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName), + ) + } + add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null))) + } + for (intent in intents) { + try { + activity.startActivity(intent) + return RESULT_OPENED + } catch (error: ActivityNotFoundException) { + // 이 화면이 없는 기기 — 다음 화면을 연다 + } catch (error: SecurityException) { + // 열 수 없다 — 다음 화면을 연다 + } + } + return RESULT_FAILED + } + + private fun clearNewMail() { + manager.cancel(NEW_MAIL_TAG, FCM_NOTIFICATION_ID) + } + + private companion object { + /** 짝: Dart `PlatformNotificationSystem.channel` */ + const val CHANNEL = "kr.it.arasystem.app/notifications" + const val STATUS_METHOD = "status" + const val OPEN_SETTINGS_METHOD = "openSettings" + const val CLEAR_NEW_MAIL_METHOD = "clearNewMail" + + // 짝: Dart `PlatformNotificationSystem` 의 appEnabledKey · channelEnabledKey · sdkIntKey · openedResult + const val KEY_APP_ENABLED = "appEnabled" + const val KEY_CHANNEL_ENABLED = "channelEnabled" + const val KEY_SDK_INT = "sdkInt" + const val RESULT_OPENED = "opened" + const val RESULT_FAILED = "failed" + + /** 짝: 서버가 보내는 알림의 tag(`android.notification.tag`) — "새 메일 N통" · "알림이 멈췄습니다"가 함께 쓴다 */ + const val NEW_MAIL_TAG = "new-mail" + + /** FCM 은 tag 를 준 알림을 id 0 으로 띄운다 */ + const val FCM_NOTIFICATION_ID = 0 + } +} diff --git a/flutter/android/app/src/main/res/values/colors.xml b/flutter/android/app/src/main/res/values/colors.xml index a8f0bf8..7df9b15 100644 --- a/flutter/android/app/src/main/res/values/colors.xml +++ b/flutter/android/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ + + #0E7C86 #0B3640 diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e0cc52d --- /dev/null +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + + new_mail + + 새 메일 + 새 메일이 오면 알려 드립니다. + diff --git a/flutter/android/settings.gradle.kts b/flutter/android/settings.gradle.kts index ca7fe06..4738537 100644 --- a/flutter/android/settings.gradle.kts +++ b/flutter/android/settings.gradle.kts @@ -21,6 +21,9 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + // 새 메일 알림(FCM) — google-services.json 을 안드로이드 리소스로 바꾼다. 4.4.4 는 firebase_core 5.1.0 이 시험한 판 + // (그 pubspec 의 google_services_gradle_plugin_version). 거는 것은 app/build.gradle.kts — 파일이 있을 때만 + id("com.google.gms.google-services") version "4.4.4" apply false } include(":app") diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index f0409f9..f4ca6c2 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -24,10 +24,23 @@ abstract final class ApiPaths { /// 문의 연락처 (로그인 없음) static const String supportContact = '/support-contact'; - /// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일") + // ── 앱 설정 · 새 메일 알림 (계약 1절 "앱 새 메일 알림 · 앱 설정") ── + /// 앱 설정 — 최소 판 · 새 메일 알림을 쓸 수 있는지 (로그인 없음) + static const String appConfig = '/app-config'; + + /// 이 세션의 새 메일 알림 등록 — `PUT`(FID · 플랫폼 · 앱 판) · `DELETE` (앱 토큰만) + static const String pushRegistration = '/push-registrations/current'; + + /// 이 세션의 "봤음" — `DELETE` 하면 서버가 "새 메일 N통"의 N 을 처음부터 센다 + static const String pushRegistrationUnseen = + '/push-registrations/current/unseen'; + + /// 401 에도 갱신하지 않고 Bearer 도 붙이지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일"), + /// 그리고 로그인 없는 앱 설정(인증 줄 밖의 Dio — `publicDioProvider` 로 보낸다. 줄 안으로 보내더라도 미리 갱신하지 않게) static const Set noRefresh = { appLogin, appRefresh, appLogout, + appConfig, }; } diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 39b1512..e60fa69 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -13,11 +13,11 @@ import 'package:dio/dio.dart'; /// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는 /// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`) /// → [ErrorNotifyInterceptor](알림 — 맨 끝). -/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다. +/// - 안쪽 Dio(갱신 · 다시 보내기 · 로그인 없는 앱 설정): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다. /// /// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙). class DioClient { - DioClient._(this.dio, this.refresher); + DioClient._(this.dio, this.refresher, this.publicDio); factory DioClient.create({ required ApiConfig config, @@ -62,7 +62,7 @@ class DioClient { ErrorNotifyInterceptor(notifier), ]); - return DioClient._(main, refresher); + return DioClient._(main, refresher, bare); } /// 화면 · 저장소가 쓰는 Dio @@ -70,4 +70,8 @@ class DioClient { /// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나) final TokenRefresher refresher; + + /// 안쪽 Dio — 로그인 없이 앱을 띄우자마자 묻는 요청(앱 설정)이 쓴다. 인증 인터셉터는 요청을 한 줄로 세워 + /// 앞 요청의 미리 갱신을 기다리게 하므로, 줄 밖에서 세션 되살리기와 나란히 보낸다. 알림 인터셉터가 없다(알리지 않는 요청만) + final Dio publicDio; } diff --git a/flutter/lib/core/api/dio_provider.dart b/flutter/lib/core/api/dio_provider.dart index 18e68a0..64cdcc3 100644 --- a/flutter/lib/core/api/dio_provider.dart +++ b/flutter/lib/core/api/dio_provider.dart @@ -68,6 +68,12 @@ final Provider dioProvider = Provider( (Ref ref) => ref.watch(dioClientProvider).dio, ); +/// 로그인 없는 공개 요청의 Dio — 인증 인터셉터 줄 밖(Bearer · 갱신 · 알림 없음, 언래핑 · 코드 정규화만). +/// 앱을 띄우자마자 세션 되살리기와 나란히 묻는 앱 설정(`GET /api/app-config`)이 쓴다 +final Provider publicDioProvider = Provider( + (Ref ref) => ref.watch(dioClientProvider).publicDio, +); + /// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다 final Provider tokenRefresherProvider = Provider( diff --git a/flutter/lib/core/api/user_agent.dart b/flutter/lib/core/api/user_agent.dart index daf7481..88f3579 100644 --- a/flutter/lib/core/api/user_agent.dart +++ b/flutter/lib/core/api/user_agent.dart @@ -44,46 +44,74 @@ String _clean(String? value) { : kept; } -/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다. -Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async { +/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번. +/// 기기 정보를 읽지 못하면 아는 것만 싣는다. +Future< + ({ + String? appVersion, + String? packageName, + String userAgent, + int? androidSdkInt, + }) +> +loadAppDeviceInfo() async { String? appVersion; + String? packageName; try { - appVersion = (await PackageInfo.fromPlatform()).version; + final PackageInfo info = await PackageInfo.fromPlatform(); + appVersion = info.version; + packageName = info.packageName.isEmpty ? null : info.packageName; } on PlatformException { appVersion = null; } + final ({String userAgent, int? androidSdkInt}) device = await _deviceInfo( + appVersion, + ); return ( appVersion: appVersion, - userAgent: await _deviceUserAgent(appVersion), + packageName: packageName, + userAgent: device.userAgent, + androidSdkInt: device.androidSdkInt, ); } -Future _deviceUserAgent(String? appVersion) async { +Future<({String userAgent, int? androidSdkInt})> _deviceInfo( + String? appVersion, +) async { try { final DeviceInfoPlugin device = DeviceInfoPlugin(); if (Platform.isAndroid) { final AndroidDeviceInfo android = await device.androidInfo; - return buildUserAgent( - appVersion: appVersion, - osName: 'Android', - osVersion: android.version.release, - model: android.model, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: 'Android', + osVersion: android.version.release, + model: android.model, + ), + androidSdkInt: android.version.sdkInt, ); } if (Platform.isIOS) { final IosDeviceInfo ios = await device.iosInfo; - return buildUserAgent( - appVersion: appVersion, - osName: 'iOS', - osVersion: ios.systemVersion, - model: ios.utsname.machine, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: 'iOS', + osVersion: ios.systemVersion, + model: ios.utsname.machine, + ), + androidSdkInt: null, ); } } on PlatformException { // 아래의 아는 것만 싣는다 } - return buildUserAgent( - appVersion: appVersion, - osName: Platform.operatingSystem, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: Platform.operatingSystem, + ), + androidSdkInt: null, ); } diff --git a/flutter/lib/core/app_config/app_config.dart b/flutter/lib/core/app_config/app_config.dart new file mode 100644 index 0000000..cc62d60 --- /dev/null +++ b/flutter/lib/core/app_config/app_config.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; + +/// 앱 판 — 숫자 셋(major.minor.patch). 판 이름(`PackageInfo.version` — pubspec 의 build-name, User-Agent 와 같은 값)을 견준다 +@immutable +class AppVersion implements Comparable { + const AppVersion(this.major, this.minor, this.patch); + + /// 판 이름을 읽는다 — `1.2` 는 `1.2.0`, `-` · `+` 뒤(미리 보기 꼬리 · 빌드 번호)는 버린다. + /// 칸이 넷 이상이거나 숫자가 아니면(칸마다 1~4자리) 읽지 못한 것(null)이다 — 칸마다 4자리는 서버의 판 칸(`APP_VERSION_PATTERN`)과 같다. + /// 5자리 칸을 받으면 서버 등록(`PUT`)이 늘 400 이 된다 + static AppVersion? tryParse(String? text) { + if (text == null) return null; + String value = text.trim(); + final int tail = value.indexOf(RegExp('[-+]')); + if (tail >= 0) value = value.substring(0, tail); + final List parts = value.split('.'); + if (parts.isEmpty || parts.length > 3) return null; + final List numbers = []; + for (final String part in parts) { + if (!_part.hasMatch(part)) return null; + numbers.add(int.parse(part)); + } + while (numbers.length < 3) { + numbers.add(0); + } + return AppVersion(numbers[0], numbers[1], numbers[2]); + } + + static final RegExp _part = RegExp(r'^\d{1,4}$'); + + final int major; + final int minor; + final int patch; + + @override + int compareTo(AppVersion other) { + if (major != other.major) return major.compareTo(other.major); + if (minor != other.minor) return minor.compareTo(other.minor); + return patch.compareTo(other.patch); + } + + bool operator <(AppVersion other) => compareTo(other) < 0; + + @override + bool operator ==(Object other) => + other is AppVersion && + other.major == major && + other.minor == minor && + other.patch == patch; + + @override + int get hashCode => Object.hash(major, minor, patch); + + @override + String toString() => '$major.$minor.$patch'; +} + +/// 서버에 알리는 앱 판 — `숫자.숫자.숫자`(서버 칸의 모양). 읽지 못하면 null +String? normalizedAppVersion(String? version) => + AppVersion.tryParse(version)?.toString(); + +/// 앱 설정 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정", 로그인 없음) +/// +/// `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: true }` — 앱은 자기 플랫폼의 최소 판만 읽는다. +@immutable +class AppConfig { + const AppConfig({this.minSupportedVersion, this.pushAvailable}); + + /// 그 경로가 없는 옛 서버(404) — 막지 않고, 새 메일 알림은 쓸 수 없는 것으로 본다 + static const AppConfig notProvided = AppConfig(pushAvailable: false); + + /// 응답의 `data` 를 읽는다 — 모양이 틀린 칸은 없는 것으로 본다(최소 판이 틀리면 막지 않는다 · 알림 칸이 틀리면 모른다) + factory AppConfig.fromJson(Object? data, {String platform = kAppPlatform}) { + if (data is! Map) return const AppConfig(); + final Object? versions = data['minSupportedVersion']; + final Object? minimum = versions is Map + ? versions[platform] + : null; + final Object? push = data['pushAvailable']; + return AppConfig( + minSupportedVersion: minimum is String + ? AppVersion.tryParse(minimum) + : null, + pushAvailable: push is bool ? push : null, + ); + } + + /// 이 판보다 낮은 앱은 쓸 수 없다 — 없으면(정하지 않음 · 읽지 못함) 막지 않는다 + final AppVersion? minSupportedVersion; + + /// 서버가 새 메일 알림을 보낼 수 있다 — 모르면 null + final bool? pushAvailable; + + @override + bool operator ==(Object other) => + other is AppConfig && + other.minSupportedVersion == minSupportedVersion && + other.pushAvailable == pushAvailable; + + @override + int get hashCode => Object.hash(minSupportedVersion, pushAvailable); +} + +/// 앱이 읽는 플랫폼 칸 — iOS 를 붙이면 플랫폼마다 고른다 +const String kAppPlatform = 'android'; + +/// 업데이트해야 하는가 — 이 앱의 판이 서버의 최소 판보다 낮을 때만. 어느 쪽이든 읽지 못하면 막지 않는다 +bool isAppUpdateRequired({ + required String? appVersion, + required AppVersion? minimum, +}) { + if (minimum == null) return false; + final AppVersion? current = AppVersion.tryParse(appVersion); + if (current == null) return false; + return current < minimum; +} diff --git a/flutter/lib/core/app_config/app_config_controller.dart b/flutter/lib/core/app_config/app_config_controller.dart new file mode 100644 index 0000000..3c814d3 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_controller.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:arasystem_app/core/app_config/app_config_repository.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱 설정을 마지막으로 받은 뒤 이만큼 지나면 앞으로 올 때 다시 묻는다 +const Duration kAppConfigRefreshAfter = Duration(hours: 1); + +/// 받지 못했으면(끊긴 망 · 5xx) 앞으로 올 때 이만큼 간격으로 다시 묻는다 +const Duration kAppConfigRetryAfter = Duration(minutes: 1); + +/// 앱 설정 상태 — 받은 값과 받은 때 · 실패한 때(기기 시계 — 간격만 잰다) +@immutable +class AppConfigState { + const AppConfigState({this.config, this.fetchedAt, this.failedAt}); + + /// 마지막으로 받은 값 — 아직 받지 못했으면 null(막지 않는다 · 알림은 쓸 수 있는 것으로) + final AppConfig? config; + final DateTime? fetchedAt; + + /// 마지막 실패 — 그 뒤에 받았으면 의미가 없다 + final DateTime? failedAt; + + @override + bool operator ==(Object other) => + other is AppConfigState && + other.config == config && + other.fetchedAt == fetchedAt && + other.failedAt == failedAt; + + @override + int get hashCode => Object.hash(config, fetchedAt, failedAt); +} + +/// 앱 설정 API +final Provider appConfigRepositoryProvider = + Provider( + (Ref ref) => AppConfigRepository(ref.watch(publicDioProvider)), + ); + +/// 앱 설정 — 앱에 하나. 라우터(최소 판)와 새 메일 알림(`pushAvailable`)이 본다. +final NotifierProvider appConfigProvider = + NotifierProvider( + AppConfigController.new, + retry: noAutomaticRetry, + ); + +/// 앱 설정을 묻는다 — 앱을 띄울 때 한 번(세션 되살리기와 나란히 — 기다리지 않는다), 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면. +/// +/// 실패(끊긴 망 · 5xx)는 알리지 않고 막지 않는다 — 앞으로 올 때 1분 간격으로 다시. 404(그 경로가 없는 옛 서버)는 받은 것으로 본다 +/// (최소 판 없음 · 알림 쓸 수 없음). 모양이 틀리면 막지 않는다. +class AppConfigController extends Notifier { + Future? _loading; + + @override + AppConfigState build() { + ref.listen(appForegroundProvider, (bool? previous, bool next) { + if (next && previous == false) refreshIfStale(); + }); + // 만들자마자 묻는다 — build 안에서는 상태를 바꾸지 않는다 + final Ref built = ref; + scheduleMicrotask(() { + if (built.mounted) unawaited(refresh()); + }); + return const AppConfigState(); + } + + /// 지금 묻는다 — 묻는 중이면 그 물음을 함께 기다린다 + Future refresh() { + final Future? running = _loading; + if (running != null) return running; + final Future started = _load(); + _loading = started; + return started.whenComplete(() { + if (identical(_loading, started)) _loading = null; + }); + } + + /// 앞으로 왔다 — 받은 지 1시간이 지났거나, 마지막이 실패였고 1분이 지났으면 묻는다 + void refreshIfStale() { + if (!ref.mounted || _loading != null) return; + final DateTime now = ref.read(clockProvider)(); + final DateTime? fetched = state.fetchedAt; + final DateTime? failed = state.failedAt; + final bool lastFailed = + failed != null && (fetched == null || failed.isAfter(fetched)); + final DateTime? since = lastFailed ? failed : fetched; + final Duration wait = lastFailed + ? kAppConfigRetryAfter + : kAppConfigRefreshAfter; + final Duration? elapsed = since == null ? null : now.difference(since); + // 시계를 뒤로 돌렸다(음수) — 지난 시간을 모르니 묻는다 + if (elapsed == null || elapsed.isNegative || elapsed >= wait) { + unawaited(refresh()); + } + } + + Future _load() async { + final Ref ref = this.ref; + try { + final AppConfig config = await ref + .read(appConfigRepositoryProvider) + .fetch(); + if (!ref.mounted) return; + state = AppConfigState( + config: config, + fetchedAt: ref.read(clockProvider)(), + ); + } on DioException { + if (!ref.mounted) return; + state = AppConfigState( + config: state.config, + fetchedAt: state.fetchedAt, + failedAt: ref.read(clockProvider)(), + ); + } + } +} + +/// 이 앱을 업데이트해야 하는가 — 라우터가 어느 화면에서든 업데이트 화면으로 옮긴다(`core/router/session_redirect.dart`) +final Provider appUpdateRequiredProvider = Provider( + (Ref ref) => isAppUpdateRequired( + appVersion: ref.watch(appVersionProvider), + minimum: ref.watch(appConfigProvider).config?.minSupportedVersion, + ), +); + +/// 서버가 새 메일 알림을 보낼 수 있는가 — 모르면 null(쓸 수 있는 것으로 본다) +final Provider pushAvailableProvider = Provider( + (Ref ref) => ref.watch(appConfigProvider).config?.pushAvailable, +); diff --git a/flutter/lib/core/app_config/app_config_repository.dart b/flutter/lib/core/app_config/app_config_repository.dart new file mode 100644 index 0000000..fe48816 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_repository.dart @@ -0,0 +1,32 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:dio/dio.dart'; + +/// 그 경로가 없을 때 서버 · 앞단 nginx 가 내는 코드 — 옛 서버로 본다 +const String _notFoundCode = 'RES_001'; + +/// 앱 설정 — `GET /api/app-config` (로그인 없음 · 알림 끔). +/// +/// 인증 인터셉터 줄 밖의 Dio(`publicDioProvider`)로 보낸다 — 앱을 띄우자마자 세션 되살리기(미리 갱신)를 기다리지 않고 나란히 묻는다. +/// 줄 안의 Dio 로 보내더라도 Bearer 를 붙이지 않고 미리 갱신하지 않는 경로다(`ApiPaths.noRefresh`). +class AppConfigRepository { + const AppConfigRepository(this._dio); + + final Dio _dio; + + /// 받는다 — 그 경로가 없으면(404 `RES_001` — 옛 서버) [AppConfig.notProvided]. 그 밖의 실패는 `DioException` + Future fetch() async { + try { + final Response response = await _dio.get( + ApiPaths.appConfig, + options: ApiOptions.silent(), + ); + return AppConfig.fromJson(response.data); + } on DioException catch (error) { + if (apiErrorCodeOf(error) == _notFoundCode) return AppConfig.notProvided; + rethrow; + } + } +} diff --git a/flutter/lib/core/constants/app_info_provider.dart b/flutter/lib/core/constants/app_info_provider.dart index 5900f14..31f9ec4 100644 --- a/flutter/lib/core/constants/app_info_provider.dart +++ b/flutter/lib/core/constants/app_info_provider.dart @@ -5,3 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final Provider appVersionProvider = Provider( (Ref ref) => null, ); + +/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다. +/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다) +final Provider appPackageNameProvider = Provider( + (Ref ref) => null, +); diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart index 5bdc98a..b25abc4 100644 --- a/flutter/lib/core/constants/screen_titles.dart +++ b/flutter/lib/core/constants/screen_titles.dart @@ -17,4 +17,7 @@ abstract final class ScreenTitles { /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) static const String twoFactorSetup = '2단계 인증을 켜 주세요'; + + /// 앱을 업데이트해야 한다 — 업데이트 화면의 제목(보드 `App1Push` ⑩) + static const String update = '앱을 업데이트해 주세요'; } diff --git a/flutter/lib/core/push/firebase_push_messaging.dart b/flutter/lib/core/push/firebase_push_messaging.dart new file mode 100644 index 0000000..2a6e685 --- /dev/null +++ b/flutter/lib/core/push/firebase_push_messaging.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; + +/// Firebase 를 띄우는 데 기다리는 가장 긴 시간 — 넘으면 알림 없이 앱을 띄운다 +const Duration kFirebaseStartTimeout = Duration(seconds: 5); + +/// 알림 권한을 묻기 시작한 안드로이드 판 — 13(API 33). 그 아래는 묻는 창이 없고 휴대폰 설정에서만 켜고 끈다 +const int kNotificationPermissionSdk = 33; + +/// firebase_messaging 의 권한 → 앱의 [PushPermission] (firebase_messaging 17 — 안드로이드는 플러그인이 "물은 적 있음"을 기록해 넷으로 가른다). +/// +/// - 허락 · 임시 허락(iOS) → granted +/// - 묻지 않음 → notAsked +/// - 거절 → 안드로이드 13 이상은 다시 물을 수 있어 denied, 12 이하는 휴대폰 설정에서 끈 것이라 blocked. +/// 판을 모르면([sdkInt] null) 다시 물을 수 있는 것으로 본다(묻는 창이 뜨지 않으면 그 결과가 진짜 상태다) +/// - 다시 묻지 못함 → blocked +PushPermission pushPermissionOf(AuthorizationStatus status, {int? sdkInt}) { + final bool canAskAgain = + sdkInt == null || sdkInt >= kNotificationPermissionSdk; + return switch (status) { + AuthorizationStatus.authorized || + AuthorizationStatus.provisional => PushPermission.granted, + AuthorizationStatus.notDetermined => PushPermission.notAsked, + AuthorizationStatus.denied => + canAskAgain ? PushPermission.denied : PushPermission.blocked, + AuthorizationStatus.deniedPermanently => PushPermission.blocked, + }; +} + +/// FCM 메시지 → [PushMessage] — data 의 모듈 · 종류만 읽는다(알림 글 · 보낸 쪽 정보는 버린다) +PushMessage pushMessageOf(RemoteMessage message) => + PushMessage.fromData(Map.of(message.data)); + +/// firebase_messaging 으로 만든 [PushMessaging] — 앱에서 firebase_messaging 을 import 하는 곳은 여기뿐이다. +/// +/// 플러그인의 소식 줄(FID · 앞에서 받은 알림 · 누른 알림)은 만들자마자 듣는다 — 플러그인 줄은 broadcast 라 듣기 전에 온 값은 사라진다. +/// 기기 주소(FID) · 알림 내용은 로그에 남기지 않는다. +class FirebasePushMessaging implements PushMessaging { + FirebasePushMessaging._(this._messaging, {int? sdkInt}) : _sdkInt = sdkInt { + _subscriptions.addAll(>[ + _messaging.onRegistered.listen(_registered.add), + FirebaseMessaging.onMessage.listen( + (RemoteMessage message) => _foreground.add(pushMessageOf(message)), + ), + FirebaseMessaging.onMessageOpenedApp.listen( + (RemoteMessage message) => _opened.add(pushMessageOf(message)), + ), + ]); + } + + /// Firebase 를 띄운다 — 안드로이드는 google-services 플러그인이 만든 리소스(`google-services.json`)에서 설정을 읽는다. + /// 띄우지 못하면(설정 파일 없이 빌드 · [timeout] 넘음) 알림 없이 도는 [UnavailablePushMessaging] 을 돌려준다 — 앱은 그대로 돈다. + /// [sdkInt] 는 안드로이드 판(API) — 권한을 넷으로 가르는 데 쓴다(모르면 null). + static Future start({ + int? sdkInt, + Duration timeout = kFirebaseStartTimeout, + }) async { + try { + await Firebase.initializeApp().timeout(timeout); + // 플러그인의 채널 처리기를 지금 건다 — 누른 알림 · 앞에서 받은 알림을 놓치지 않게 + return FirebasePushMessaging._( + FirebaseMessaging.instance, + sdkInt: sdkInt, + ); + } on Object { + // "Failed to load FirebaseOptions from resource"(설정 파일 없음) · 시간 넘음 · 플러그인 없음 + return const UnavailablePushMessaging(); + } + } + + final FirebaseMessaging _messaging; + final int? _sdkInt; + + // 앱이 떠 있는 동안 내내 듣는다(닫지 않는다) + final List> _subscriptions = + >[]; + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.broadcast(); + + @override + bool get isAvailable => true; + + @override + Future permission() async { + final NotificationSettings settings = await _messaging + .getNotificationSettings(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future requestPermission() async { + final NotificationSettings settings = await _messaging.requestPermission(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future register() => _messaging.register(); + + @override + Future unregister() => _messaging.unregister(); + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future initialOpen() async { + final RemoteMessage? message = await _messaging.getInitialMessage(); + return message == null ? null : pushMessageOf(message); + } +} diff --git a/flutter/lib/core/push/notification_system.dart b/flutter/lib/core/push/notification_system.dart new file mode 100644 index 0000000..5a643f9 --- /dev/null +++ b/flutter/lib/core/push/notification_system.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 휴대폰의 알림 설정 — 앱 알림 전체 · 새 메일 채널 · 안드로이드 판 +@immutable +class NotificationStatus { + const NotificationStatus({ + required this.appEnabled, + required this.channelEnabled, + this.sdkInt, + }); + + /// 읽지 못했다 — 막지 않는 쪽(켜짐)으로 본다 + static const NotificationStatus unknown = NotificationStatus( + appEnabled: true, + channelEnabled: true, + ); + + /// 이 앱의 알림 전체가 켜져 있다(안드로이드 `areNotificationsEnabled`) + final bool appEnabled; + + /// 새 메일 채널이 켜져 있다 — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다(채널이 없는 판은 참) + final bool channelEnabled; + + /// 안드로이드 판(API) — 모르면 null + final int? sdkInt; + + @override + bool operator ==(Object other) => + other is NotificationStatus && + other.appEnabled == appEnabled && + other.channelEnabled == channelEnabled && + other.sdkInt == sdkInt; + + @override + int get hashCode => Object.hash(appEnabled, channelEnabled, sdkInt); +} + +/// 휴대폰의 알림창 · 알림 설정 — 포트다(안드로이드는 [PlatformNotificationSystem], iOS 는 나중에 구현을 더한다) +abstract interface class NotificationSystem { + /// 지금 알림 설정 — 읽지 못하면 [NotificationStatus.unknown] + Future status(); + + /// 휴대폰의 알림 설정 화면을 연다 — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참 + Future openSettings(); + + /// 알림창의 "새 메일 N통"을 지운다 — 다른 알림은 건드리지 않는다 + Future clearNewMail(); +} + +/// 안드로이드 알림 채널(`MainActivity.kt` → `NewMailNotifications.kt`) +/// +/// **짝: Kotlin `NewMailNotifications`** — 채널 이름 · 부르는 이름 · 답의 칸과 값([statusMethod] 의 [appEnabledKey] · [channelEnabledKey] · [sdkIntKey], +/// [openSettingsMethod] 의 [openedResult]). 한쪽을 고치면 둘 다. +class PlatformNotificationSystem implements NotificationSystem { + const PlatformNotificationSystem(); + + static const MethodChannel channel = MethodChannel( + 'kr.it.arasystem.app/notifications', + ); + + static const String statusMethod = 'status'; + static const String openSettingsMethod = 'openSettings'; + static const String clearNewMailMethod = 'clearNewMail'; + + static const String appEnabledKey = 'appEnabled'; + static const String channelEnabledKey = 'channelEnabled'; + static const String sdkIntKey = 'sdkInt'; + + static const String openedResult = 'opened'; + + @override + Future status() async { + try { + final Map? reply = await channel + .invokeMapMethod(statusMethod); + return notificationStatusOf(reply); + } on PlatformException { + return NotificationStatus.unknown; + } on MissingPluginException { + return NotificationStatus.unknown; + } + } + + @override + Future openSettings() async { + try { + return await channel.invokeMethod(openSettingsMethod) == + openedResult; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } + } + + @override + Future clearNewMail() async { + try { + await channel.invokeMethod(clearNewMailMethod); + } on PlatformException { + // 지우지 못했다 — 다음에 앞으로 올 때 다시 지운다 + } on MissingPluginException { + // 채널이 없는 플랫폼 + } + } +} + +/// 채널 답 → [NotificationStatus] — 모양이 틀린 칸은 막지 않는 쪽(켜짐 · 판 모름)으로 본다 +NotificationStatus notificationStatusOf(Map? reply) { + if (reply == null) return NotificationStatus.unknown; + final Object? app = reply[PlatformNotificationSystem.appEnabledKey]; + final Object? channel = reply[PlatformNotificationSystem.channelEnabledKey]; + final Object? sdkInt = reply[PlatformNotificationSystem.sdkIntKey]; + return NotificationStatus( + appEnabled: app is bool ? app : true, + channelEnabled: channel is bool ? channel : true, + sdkInt: sdkInt is int ? sdkInt : null, + ); +} + +/// 알림창 · 알림 설정 — 시험은 가짜로 덮어쓴다 +final Provider notificationSystemProvider = + Provider( + (Ref ref) => const PlatformNotificationSystem(), + ); diff --git a/flutter/lib/core/push/push_controller.dart b/flutter/lib/core/push/push_controller.dart new file mode 100644 index 0000000..2571362 --- /dev/null +++ b/flutter/lib/core/push/push_controller.dart @@ -0,0 +1,678 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// "봤음"(`DELETE …/unseen`)을 보내는 가장 짧은 간격 — 그 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다 +const Duration kPushSeenInterval = Duration(seconds: 30); + +/// 직원이 켤 때 FCM 등록 소식(FID)을 기다리는 가장 긴 시간 — 넘으면 켜지 못한 것으로 알린다 +const Duration kPushRegisterWait = Duration(seconds: 15); + +/// 새 메일 알림 — 앱에 하나. 세션 노티파이어가 [PushRegistrar] 로 부르고, 설정 › 새 메일 알림 줄 · 받은편지함 위 권하기 줄이 상태를 본다. +final NotifierProvider pushControllerProvider = + NotifierProvider(PushController.new); + +/// 새 메일 알림 컨트롤러 — 계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘". +/// +/// - **맞추기**: "보내야 하나"(켬 ∧ 권한 ∧ 새 메일 채널 — [pushWanted])와 "맞출 수 있나"(받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음 ∧ +/// 메일을 쓸 수 있는 세션 — [pushReachable])를 보고 FCM 등록 · `PUT` · `DELETE` 를 고른다. 서버 일은 한 번에 하나씩(줄 세움). +/// `PUT` 은 FID 를 받았을 때 · FID 가 바뀌었을 때 · 세션이 열릴 때 한 번(앱을 띄울 때 — 판 · 줄 되살리기) · 직원이 켤 때만 — 앞으로 올 때마다 부르지 않는다. +/// `DELETE` 는 직원이 끌 때 · 권한이나 채널이 꺼진 것을 알았을 때. 로그아웃 · 세션 끝에는 부르지 않는다 — 세션 노티파이어가 토큰을 지운 뒤 +/// 서버 logout 과 함께 [unregister] 를 부르고(서로 기다리지 않는다), 여기서는 알림창을 지우고 FCM 등록만 푼다(서버 줄은 서버가 세션과 함께 지운다). +/// - **세션**: 세션 세대([_epoch] — register · unregister · pause 마다 오른다)가 바뀌면 늦은 결과를 버린다. 세션 노티파이어는 이것을 [pushRegistrarProvider] 로만 알고, +/// 이것은 세션을 watch 하지 않는다(서로 물리지 않게). +/// - **FCM 등록 세대**([_fcmGeneration] — 풀기를 시작할 때마다 오른다): 풀기 앞의 등록은 끝나도 마친 것으로 치지 않고 "풀지 못한 등록" 표시도 지우지 않는다. +/// 다음 세션의 등록은 풀기 뒤에 줄을 선다 — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 다시 등록한다. +/// - **앞으로 옴**: 알림창의 "새 메일 N통"을 지우고, 권한 · 채널을 다시 읽어 맞추고, "봤음"을 보낸다(30초 간격 — 건너뛰면 끝에 한 번). +/// 앞에 있는 동안 온 새 메일 알림도 "봤음"을 보내고 메일 모듈에 알린다([pushEventsProvider] — 새 메일 확인을 바로). +/// 봤음은 앱이 앞에 있을 때만 보낸다 — 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다). +/// - **누른 알림**: [pushEventsProvider] 에 값으로 올린다 — 라우터가 가져간다(`core/router/push_navigation.dart`). +/// - Firebase 를 띄우지 못한 휴대폰([PushMessaging.isAvailable] 이 거짓)에서는 아무것도 하지 않는다. +/// - FID · 주소 · 토큰은 로그에 남기지 않는다. +class PushController extends Notifier implements PushRegistrar { + late PushMessaging _messaging; + late NotificationSystem _notifications; + late PushPreferences _prefs; + + final List> _subscriptions = + >[]; + + /// 이 휴대폰의 값 · 권한을 다 읽었다 — 맞추기 · 켜기 · 끄기가 먼저 기다린다 + Future _ready = Future.value(); + + /// 서버 일 · 기기 읽기를 차례로 + Future _queue = Future.value(); + bool _reconcileQueued = false; + + /// FCM 등록 · 풀기를 차례로(플러그인은 둘을 다른 스레드에서 돌린다) + Future _fcmQueue = Future.value(); + + /// 세션 세대 — register · unregister · pause 마다 오른다 + int _epoch = 0; + bool _sessionReady = false; + + /// 이 세션의 서버 줄 — 있음(true) · 없음(false) · 모름(null) + bool? _serverRow = false; + + /// 이 세션에 이번 실행에서 올린 FID — 세션이 열릴 때마다 비운다(그 세션에서 한 번은 올린다) + String? _serverFid; + + /// FCM 이 알려 준 FID — 또는 지난 실행에 올린 것(FCM 등록이 그대로인 동안) + String? _fid; + + /// FCM 등록을 바란다(맞추기가 시작했다 — 풀 때까지) · 이번 실행에서 등록을 마쳤다 + bool _fcmWanted = false; + bool _fcmRegistered = false; + Future? _fcmRegistering; + bool _pendingUnregister = false; + + /// FCM 등록 세대 — 풀기를 시작할 때마다 오른다. 등록은 시작한 세대가 그대로일 때만 마친 것으로 친다 + int _fcmGeneration = 0; + + /// 줄에 섰거나 도는 풀기의 수 — 그동안 온 등록 소식은 풀리는 등록의 것이라 쓰지 않는다 + int _fcmUnregistering = 0; + final List> _fidWaiters = >[]; + + // ── 봤음 ── + bool _seenPending = false; + DateTime? _lastSeenAt; + Timer? _seenTimer; + + PushRegistrationRepository get _repository => + ref.read(pushRegistrationRepositoryProvider); + + TokenStore get _store => ref.read(tokenStoreProvider); + + Clock get _clock => ref.read(clockProvider); + + ErrorNotifier get _errors => ref.read(errorNotifierProvider); + + String get _appVersion => + normalizedAppVersion(ref.read(appVersionProvider)) ?? kUnknownAppVersion; + + @override + PushState build() { + _messaging = ref.read(pushMessagingProvider); + _notifications = ref.read(notificationSystemProvider); + _prefs = ref.read(pushPreferencesProvider); + ref.onDispose(_dispose); + if (!_messaging.isAvailable) return const PushState(loaded: true); + + _subscriptions.addAll(>[ + _messaging.onRegistered.listen(_onRegistered), + _messaging.onForegroundMessage.listen(_onForegroundMessage), + _messaging.onOpened.listen(_onOpened), + ]); + ref + ..listen(appForegroundProvider, (bool? previous, bool next) { + if (next && previous == false) _cameToForeground(); + if (!next && previous == true) _wentToBackground(); + }) + ..listen(pushAvailableProvider, (bool? previous, bool? next) { + state = state.copyWith(server: _serverOf(next)); + _requestReconcile(); + }); + // 꺼져 있던 앱을 띄운 알림 — 라우터가 가져간다 + unawaited( + _messaging.initialOpen().then((PushMessage? opened) { + if (opened != null && ref.mounted) { + ref.read(pushEventsProvider.notifier).add(opened, opened: true); + } + }, onError: (Object _, StackTrace _) {}), + ); + // build 안에서는 상태를 바꾸지 않는다 — 다음 차례에 읽기 시작한다 + _ready = Future.microtask(_initialize); + return PushState( + deviceSupported: true, + server: _serverOf(ref.read(pushAvailableProvider)), + ); + } + + // ── 화면이 부르는 일 ─────────────────────────────── + + /// [알림 켜기](권하기 줄) · 설정 스위치를 켰다 — 권한이 없으면 먼저 시스템 창(직원이 누른 뒤에만). 허락되면 FCM 등록 → 서버 등록(`PUT`)이 + /// 끝나야 켬을 둔다. 서버 등록이 실패하면 이 휴대폰의 값을 끔으로 두고(스위치가 꺼진 채로 돌아간다 — 처음 값 켬에서 켰어도, 저절로 다시 하지 않는다) + /// 그 코드의 사전 문구를 알린다(인터셉터 · 등록 소식이 없으면 `SYS_001`) + Future turnOn() async { + await _ready; + if (!ref.mounted) return PushTurnOnResult.failed; + if (state.change != null) return PushTurnOnResult.busy; + if (!state.deviceSupported || + state.server == PushServer.unavailable || + !_sessionReady) { + return PushTurnOnResult.unavailable; + } + final int epoch = _epoch; + state = state.copyWith(change: PushChange.turningOn); + try { + PushPermission permission = await _readPermission(); + if (!_current(epoch)) return PushTurnOnResult.failed; + if (permission != PushPermission.granted) { + if (permission == PushPermission.blocked) { + state = state.copyWith(permission: permission); + return PushTurnOnResult.blocked; + } + permission = await _askPermission(); + if (!ref.mounted) return PushTurnOnResult.failed; + state = state.copyWith(permission: permission); + if (permission != PushPermission.granted) { + return permission == PushPermission.blocked + ? PushTurnOnResult.blocked + : PushTurnOnResult.denied; + } + } + final NotificationStatus status = await _notifications.status(); + if (!ref.mounted) return PushTurnOnResult.failed; + state = state.copyWith( + permission: permission, + channelEnabled: status.channelEnabled, + sdkInt: status.sdkInt, + ); + if (!status.channelEnabled) return PushTurnOnResult.channelOff; + // 켬 · 끔은 줄 안에서 둔다 — 뒤에 선 맞추기가 바뀐 값을 보게 + final _UserRegistration outcome = await _serial(() async { + final _UserRegistration result = await _registerByUser(epoch); + if (!ref.mounted) return _UserRegistration.skipped; + switch (result) { + case _UserRegistration.registered: + state = state.copyWith(enabled: true); + unawaited(_prefs.setEnabled(true)); + case _UserRegistration.failed: + // 직원이 켠 것이 실패했다 — 끔으로 둔다(스위치 · 줄이 꺼짐으로 돌아가고 저절로 다시 하지 않는다) + state = state.copyWith(enabled: false); + unawaited(_prefs.setEnabled(false)); + case _UserRegistration.skipped: + break; + } + return result; + }); + return outcome == _UserRegistration.registered + ? PushTurnOnResult.on + : PushTurnOnResult.failed; + } finally { + if (ref.mounted) state = state.copyWith(clearChange: true); + } + } + + /// 설정 스위치를 껐다 — 서버 등록을 지워야(`DELETE`) 끔을 둔다. 지우지 못하면 켬 그대로(스위치는 되돌린다 — 사전 문구는 인터셉터가 알렸다). + /// 끊긴 망에서는 끌 수 없다 — 휴대폰 설정에서 끌 수 있다 + Future turnOff() async { + await _ready; + if (!ref.mounted) return PushTurnOffResult.failed; + if (state.change != null) return PushTurnOffResult.busy; + final int epoch = _epoch; + state = state.copyWith(change: PushChange.turningOff); + try { + // 줄 안에서 본다 — 앞에 선 등록(PUT)이 끝난 뒤의 서버 줄로 정하고, 끔도 줄 안에서 둔다 + final bool done = await _serial(() async { + final bool talk = + _sessionReady && _store.hasSession && state.deviceSupported; + if (talk && _serverRow != false) { + if (!await _delete(epoch: epoch, silent: false)) return false; + } + if (!ref.mounted) return false; + state = state.copyWith(enabled: false); + unawaited(_prefs.setEnabled(false)); + return true; + }); + return done ? PushTurnOffResult.off : PushTurnOffResult.failed; + } finally { + if (ref.mounted) state = state.copyWith(clearChange: true); + } + } + + /// 받은편지함 위 권하기 줄의 [✕] — 이 휴대폰에서 다시 보이지 않는다 + void dismissOffer() { + if (!ref.mounted || state.offerDismissed) return; + state = state.copyWith(offerDismissed: true); + unawaited(_prefs.setOfferDismissed(true)); + } + + /// [설정 열기] — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참(돌아오면 앞으로 옴이 다시 읽는다) + Future openSettings() => _notifications.openSettings(); + + // ── 세션 노티파이어가 부르는 일 (PushRegistrar) ────── + + @override + Future register() async { + // 세션 노티파이어가 상태를 바꾸는 중에 부를 수 있다 — 다음 차례에 바꾼다 + await Future.value(); + if (!ref.mounted || !state.deviceSupported) return; + _epoch++; + _sessionReady = true; + // 세션이 열릴 때마다 한 번은 올린다(앱을 띄울 때 — 판 · 줄 되살리기) + _serverFid = null; + if (ref.read(appForegroundProvider)) _seenPending = true; + state = state.copyWith(sessionReady: true); + _requestReconcile(); + } + + @override + Future unregister() async { + if (!ref.mounted) return; + _epoch++; + final int epoch = _epoch; + _sessionReady = false; + _serverFid = null; + _cancelSeen(); + _completeFidWaiters(null); + if (!state.deviceSupported) return; + // 지난 실행의 값(FID · 풀지 못한 등록)을 읽은 뒤에 정한다 + await _ready; + // 그사이 다음 세션이 열렸다(다른 직원의 로그인) — 그 세션이 등록한다 + if (!ref.mounted || epoch != _epoch) return; + final bool hadRegistration = + _fcmWanted || _fcmRegistered || _fid != null || _pendingUnregister; + _fcmWanted = false; + _fid = null; + _serverRow = false; + state = state.copyWith(sessionReady: false, registered: false); + unawaited(_prefs.setLastFid(null)); + unawaited(_prefs.setServerRegistered(false)); + await _notifications.clearNewMail(); + if (hadRegistration && ref.mounted && epoch == _epoch) { + await _unregisterFcm(); + } + } + + @override + void pause() { + if (!ref.mounted) return; + _epoch++; + _sessionReady = false; + _serverFid = null; + _cancelSeen(); + if (!state.deviceSupported) return; + scheduleMicrotask(() { + if (ref.mounted && !_sessionReady) { + state = state.copyWith(sessionReady: false); + } + }); + } + + // ── 읽기 · 맞추기 ───────────────────────────────── + + Future _initialize() async { + try { + final PushPreferenceValues values = await _prefs.load(); + if (!ref.mounted) return; + _pendingUnregister = values.pendingUnregister; + // 풀지 못한 등록이 남았으면 지난 FID 는 믿지 않는다(다시 등록하고 새 소식을 기다린다) + _fid = values.pendingUnregister ? null : values.lastFid; + _serverRow = values.serverRegistered ? null : false; + state = state.copyWith( + enabled: values.enabled, + offerDismissed: values.offerDismissed, + ); + await _readDevice(); + } on Object { + // 읽지 못한 값은 처음 값으로 — 켤 때 다시 묻는다 + } + if (!ref.mounted) return; + state = state.copyWith(loaded: true); + // 앱을 띄웠다 — 앞에 있으면 앞으로 온 것과 같다(알림창 지우기 · 봤음) + if (ref.read(appForegroundProvider)) { + unawaited(_notifications.clearNewMail()); + if (_sessionReady) _seenPending = true; + } + _retryUnregisterIfSignedOut(); + _requestReconcile(); + } + + /// 알림 권한 · 새 메일 채널을 읽는다 — 읽지 못한 권한은 한 번 거절한 것으로 본다(켤 때 다시 묻는다 — 창이 뜨지 않으면 그 결과가 진짜 상태다) + Future _readDevice() async { + final PushPermission permission = await _readPermission(); + final NotificationStatus status = await _notifications.status(); + if (!ref.mounted) return; + state = state.copyWith( + permission: permission, + channelEnabled: status.channelEnabled, + sdkInt: status.sdkInt, + ); + } + + Future _readPermission() async { + try { + return await _messaging.permission(); + } on Object { + return state.permission ?? PushPermission.denied; + } + } + + Future _askPermission() async { + try { + return await _messaging.requestPermission(); + } on Object { + return _readPermission(); + } + } + + /// 맞추기를 줄에 세운다 — 이미 기다리는 맞추기가 있으면 그것이 한다 + void _requestReconcile() { + if (_reconcileQueued || !ref.mounted || !state.deviceSupported) return; + _reconcileQueued = true; + unawaited( + _serial(() async { + _reconcileQueued = false; + try { + await _reconcile(); + } on Object { + // 다음 맞추기(앞으로 옴 · 등록 소식 · 다음 로그인)에 다시 + } + }), + ); + } + + Future _reconcile() async { + await _ready; + if (!ref.mounted || !_canReachServer) return; + final int epoch = _epoch; + if (pushWanted(state)) { + _startFcmRegistration(); + final String? fid = _fid; + // FID 가 아직 없다 — 등록 소식(onRegistered)이 다시 맞춘다 + if (fid == null) return; + if (_serverRow == true && _serverFid == fid) return; + await _put(fid, epoch: epoch, silent: true); + } else if (_serverRow != false) { + await _delete(epoch: epoch, silent: true); + } + } + + /// 직원이 켰다 — FID 를 기다려(15초) 알림을 켠 채 올린다. 그사이 세션이 바뀌었으면 건너뛴다(켬 · 끔을 건드리지 않는다) + Future<_UserRegistration> _registerByUser(int epoch) async { + if (!_current(epoch) || !_canReachServer) return _UserRegistration.skipped; + _startFcmRegistration(); + final String? fid = _fid ?? await _waitForFid(); + if (!_current(epoch)) return _UserRegistration.skipped; + if (fid == null) { + // 요청을 보내지 못했다 — 인터셉터가 알리지 않으니 여기서 사전 문구로 + _errors.show(ApiException.fromCode(kFallbackCode)); + return _UserRegistration.failed; + } + if (_serverRow == true && _serverFid == fid) { + return _UserRegistration.registered; + } + if (await _put(fid, epoch: epoch, silent: false)) { + return _UserRegistration.registered; + } + return _current(epoch) + ? _UserRegistration.failed + : _UserRegistration.skipped; + } + + Future _put( + String fid, { + required int epoch, + required bool silent, + }) async { + try { + await _repository.put(fid: fid, appVersion: _appVersion, silent: silent); + } on DioException { + // 알리지 않는다(저절로 맞추기) — 다음 앞으로 옴 · 다음 등록 소식 · 다음 로그인에 다시 + return false; + } + if (!_current(epoch)) return false; + _serverRow = true; + _serverFid = fid; + state = state.copyWith(registered: true); + unawaited(_prefs.setLastFid(fid)); + unawaited(_prefs.setServerRegistered(true)); + _flushSeen(); + return true; + } + + Future _delete({required int epoch, required bool silent}) async { + try { + await _repository.delete(silent: silent); + } on DioException { + return false; + } + if (!ref.mounted || epoch != _epoch) return false; + _serverRow = false; + _serverFid = null; + state = state.copyWith(registered: false); + unawaited(_prefs.setServerRegistered(false)); + return true; + } + + /// 서버와 맞출 수 있는가 — 세션 · 토큰 · 휴대폰 · 서버. 화면 상태(`sessionReady`)는 [pause] 의 다음 차례에야 내려가므로 + /// 이 컨트롤러가 곧바로 아는 [_sessionReady] 도 본다(멈춘 바로 그 차례에 줄 선 맞추기가 제한된 세션으로 보내지 않게) + bool get _canReachServer => + _sessionReady && pushReachable(state) && _store.hasSession; + + /// 그 세션 그대로인가 — 늦은 결과를 버린다 + bool _current(int epoch) => ref.mounted && epoch == _epoch && _sessionReady; + + // ── FCM ─────────────────────────────────────── + + /// FCM 등록을 시작한다 — 이번 실행에서 한 번(실패하면 다음 맞추기에 다시). FID 는 등록 소식으로 온다. + /// 풀기가 줄에 섰거나 도는 중이면 그 뒤에 선다([_fcm] 줄 — 풀기가 [_fcmRegistered] · [_fcmRegistering] 을 먼저 내려 둔다) + void _startFcmRegistration() { + _fcmWanted = true; + if (_fcmRegistered || _fcmRegistering != null) return; + final int generation = _fcmGeneration; + final Future registering = _fcm(() async { + await _messaging.register(); + // 그사이 풀기를 바랐으면(로그아웃) 이 등록은 마친 것으로 치지 않는다 — 뒤에 선 풀기가 푼다 + if (generation == _fcmGeneration) _fcmRegistered = true; + }); + _fcmRegistering = registering; + unawaited( + registering + .then( + (void _) { + // 풀기를 바라지 않은 채 끝났을 때만 — 풀지 못한 등록이 이 등록으로 이어졌다(같은 FID 의 PUT 이 서버의 옛 줄을 지운다) + if (generation == _fcmGeneration && + _pendingUnregister && + ref.mounted) { + _pendingUnregister = false; + unawaited(_prefs.setPendingUnregister(false)); + } + }, + onError: (Object _, StackTrace _) { + // 다음 맞추기(앞으로 옴 · 다음 로그인)에 다시 — 기다리던 켜기는 끝낸다. + // 그사이 풀기를 시작했으면 풀린 등록의 실패다 — 다음 세션이 기다리는 켜기는 그 세션의 등록을 기다린다 + if (generation == _fcmGeneration) _completeFidWaiters(null); + }, + ) + .whenComplete(() { + if (identical(_fcmRegistering, registering)) { + _fcmRegistering = null; + } + }), + ); + } + + /// FCM 등록을 푼다 — 못 풀면 표시를 남겨 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시. + /// 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다 — 풀기가 실패하거나 오래 걸려도 다음 세션의 등록이 이 풀기 뒤에 줄을 선다 + Future _unregisterFcm() async { + _fcmGeneration++; + final int generation = _fcmGeneration; + _fcmRegistered = false; + _fcmRegistering = null; + _fcmUnregistering++; + _pendingUnregister = true; + unawaited(_prefs.setPendingUnregister(true)); + try { + await _fcm(_messaging.unregister); + } on Object { + return; + } finally { + _fcmUnregistering--; + } + // 그사이 다시 등록하기 시작했거나(다른 직원의 로그인) 또 풀기를 시작했으면 그쪽이 표시를 정한다 + if (!ref.mounted || _fcmWanted || generation != _fcmGeneration) return; + _pendingUnregister = false; + unawaited(_prefs.setPendingUnregister(false)); + } + + void _retryUnregisterIfSignedOut() { + if (!_pendingUnregister || _fcmWanted || _store.hasSession) return; + unawaited(_unregisterFcm()); + } + + void _onRegistered(String fid) { + // 풀었거나 아직 바라지 않는 등록 · 풀리는 중인 등록의 소식은 쓰지 않는다 + if (!ref.mounted || !_fcmWanted || _fcmUnregistering > 0) return; + _fid = fid; + _completeFidWaiters(fid); + _requestReconcile(); + } + + Future _waitForFid() async { + final Completer waiter = Completer(); + _fidWaiters.add(waiter); + try { + return await waiter.future.timeout(kPushRegisterWait); + } on TimeoutException { + return null; + } finally { + _fidWaiters.remove(waiter); + } + } + + void _completeFidWaiters(String? fid) { + for (final Completer waiter in List>.of( + _fidWaiters, + )) { + if (!waiter.isCompleted) waiter.complete(fid); + } + } + + // ── 앞으로 옴 · 받은 알림 · 봤음 ─────────────────── + + void _cameToForeground() { + if (!ref.mounted || !state.deviceSupported) return; + unawaited(_notifications.clearNewMail()); + if (_sessionReady) _seenPending = true; + unawaited( + _serial(() async { + await _ready; + await _readDevice(); + }).then((void _) { + if (!ref.mounted) return; + _requestReconcile(); + _flushSeen(); + }, onError: (Object _, StackTrace _) {}), + ); + _retryUnregisterIfSignedOut(); + } + + void _onForegroundMessage(PushMessage message) { + if (!ref.mounted) return; + ref.read(pushEventsProvider.notifier).add(message, opened: false); + if (message.isNewMail && _sessionReady) { + _seenPending = true; + _flushSeen(); + } + } + + void _onOpened(PushMessage message) { + if (!ref.mounted) return; + ref.read(pushEventsProvider.notifier).add(message, opened: true); + } + + /// 앱이 뒤로 갔다 — 간격 끝을 기다리던 봤음은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다) + void _wentToBackground() { + final Timer? timer = _seenTimer; + if (!ref.mounted || timer == null) return; + timer.cancel(); + _seenTimer = null; + if (_seenPending && _canSendSeen) _sendSeen(_clock()); + } + + /// 봤음을 보낼 수 있는가 — 이 세션의 서버 줄이 있을 때만(없으면 등록 뒤에) + bool get _canSendSeen => + _sessionReady && _serverRow == true && _store.hasSession; + + /// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번 + void _flushSeen() { + if (!_seenPending || !ref.mounted || _seenTimer != null) return; + // 뒤에 있으면 보내지 않는다 — 앞으로 올 때 다시 빚진다 + if (!_canSendSeen || !ref.read(appForegroundProvider)) return; + final DateTime now = _clock(); + final DateTime? last = _lastSeenAt; + final Duration? since = last == null ? null : now.difference(last); + if (since == null || since.isNegative || since >= kPushSeenInterval) { + _sendSeen(now); + return; + } + _seenTimer = Timer(kPushSeenInterval - since, () { + _seenTimer = null; + _flushSeen(); + }); + } + + void _sendSeen(DateTime now) { + _seenPending = false; + _lastSeenAt = now; + unawaited( + _repository.markSeen().then( + (void _) {}, + // 알리지 않는다 — 다음 앞으로 옴에 다시 + onError: (Object _, StackTrace _) {}, + ), + ); + } + + void _cancelSeen() { + _seenPending = false; + _seenTimer?.cancel(); + _seenTimer = null; + } + + // ── 차례 ────────────────────────────────────── + + Future _serial(Future Function() operation) { + final Future result = _queue.then((void _) => operation()); + _queue = result.then((T _) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + Future _fcm(Future Function() operation) { + final Future result = _fcmQueue.then((void _) => operation()); + _fcmQueue = result.then( + (T _) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + static PushServer _serverOf(bool? available) => switch (available) { + true => PushServer.available, + false => PushServer.unavailable, + null => PushServer.unknown, + }; + + void _dispose() { + for (final StreamSubscription subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + _seenTimer?.cancel(); + _seenTimer = null; + _completeFidWaiters(null); + } +} + +/// 직원이 켠 등록의 결과 — 실패면 이 휴대폰의 값을 끔으로, 건너뜀(그사이 세션이 바뀜)이면 켬 · 끔을 건드리지 않는다 +enum _UserRegistration { registered, failed, skipped } diff --git a/flutter/lib/core/push/push_events.dart b/flutter/lib/core/push/push_events.dart new file mode 100644 index 0000000..0683d9f --- /dev/null +++ b/flutter/lib/core/push/push_events.dart @@ -0,0 +1,60 @@ +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 받은 알림 소식 — 앱이 앞에 있을 때 온 알림(알림창에 띄우지 않는다)과 직원이 누른 알림 +@immutable +class PushEvent { + const PushEvent({ + required this.seq, + required this.message, + required this.opened, + }); + + /// 소식마다 하나씩 오른다 + final int seq; + + final PushMessage message; + + /// 직원이 누른 알림 — 거짓이면 앞에 있을 때 온 알림 + final bool opened; + + @override + bool operator ==(Object other) => + other is PushEvent && + other.seq == seq && + other.message == message && + other.opened == opened; + + @override + int get hashCode => Object.hash(seq, message, opened); +} + +/// 받은 알림 소식 — 새 메일 알림 컨트롤러가 올리고, 메일 모듈(새 메일 확인을 바로) · 라우터(누른 알림 — `core/router/push_navigation.dart`)가 듣는다. +/// core/push 는 라우터 · 모듈을 모른다 — 듣는 쪽이 고른다. +final NotifierProvider pushEventsProvider = + NotifierProvider(PushEvents.new); + +class PushEvents extends Notifier { + PushMessage? _pendingOpen; + + @override + PushEvent? build() => null; + + /// 소식을 올린다 — 누른 알림은 라우터가 가져갈 때까지 값으로 든다(앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않게) + void add(PushMessage message, {required bool opened}) { + if (opened) _pendingOpen = message; + state = PushEvent( + seq: (state?.seq ?? 0) + 1, + message: message, + opened: opened, + ); + } + + /// 아직 가져가지 않은 누른 알림을 가져간다 — 한 번만(상태는 바꾸지 않는다 — 다른 프로바이더를 만드는 중에도 부를 수 있게) + PushMessage? takeOpened() { + final PushMessage? opened = _pendingOpen; + _pendingOpen = null; + return opened; + } +} diff --git a/flutter/lib/core/push/push_messaging.dart b/flutter/lib/core/push/push_messaging.dart new file mode 100644 index 0000000..be4da24 --- /dev/null +++ b/flutter/lib/core/push/push_messaging.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 알림 권한 — 플랫폼마다 다른 값을 넷으로 묶는다(안드로이드는 `firebase_push_messaging.dart` 의 `pushPermissionOf`). +enum PushPermission { + /// 알림을 보여도 된다 + granted, + + /// 아직 묻지 않았다 — [알림 켜기]가 시스템 창을 띄운다(안드로이드 13 이상) + notAsked, + + /// 한 번 거절했다 — 다시 물을 수 있다(안드로이드 13 이상) + denied, + + /// 휴대폰 설정에서만 켤 수 있다 — 두 번 거절했거나, 안드로이드 12 이하에서 꺼져 있다 + blocked, +} + +/// 알림 종류 — 서버가 data 의 `kind` 칸에 싣는다(짝: 서버 `modules/push/push.constants.ts`) +abstract final class PushKinds { + /// "새 메일 N통" + static const String newMail = 'new-mail'; + + /// 세션이 끝나 알림이 멈췄다 — 저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 등(보내는 때는 서버 `SESSION_ENDED_NOTICE` · + /// 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘"). 같은 tag 로 "새 메일 N통"을 바꿔 쓴다. + /// 앱은 새 메일로 보지 않고(확인 · 봤음 없음), 누르면 다른 알림처럼 메일 탭으로 간다 — 세션이 없으니 첫 요청에서 로그인 화면이다 + static const String sessionEnded = 'session-ended'; +} + +/// 받은 알림 — 데이터의 모듈 · 종류만 읽는다(보낸 사람 · 제목 같은 내용은 싣지 않는다 — push_target.dart) +@immutable +class PushMessage { + const PushMessage({this.target, this.kind}); + + /// 알림 데이터(`{ module: 'mail', kind: 'new-mail' }`)를 읽는다 — 모르는 모듈이면 [target] 이 null + factory PushMessage.fromData(Map data) { + final Object? kind = data[kindKey]; + return PushMessage( + target: PushTarget.fromData(data), + kind: kind is String && kind.isNotEmpty ? kind : null, + ); + } + + /// 알림 데이터의 종류 칸 — 서버와 짝이다 + static const String kindKey = 'kind'; + + /// 갈 곳 — 모르는 모듈이면 null(누르면 홈) + final PushTarget? target; + + /// 알림 종류([PushKinds]) — 없으면 null + final String? kind; + + /// 메일 모듈의 새 메일 소식인가 — 종류가 없는 메일 알림도 새 메일로 본다 + bool get isNewMail => + target?.module == AppModuleId.mail && + (kind == null || kind == PushKinds.newMail); + + @override + bool operator ==(Object other) => + other is PushMessage && + other.kind == kind && + other.target?.module == target?.module && + other.target?.itemId == target?.itemId; + + @override + int get hashCode => Object.hash(kind, target?.module, target?.itemId); +} + +/// 휴대폰의 알림 받기(FCM) — 포트다. 앱 코드는 이것만 알고, firebase_messaging 은 `firebase_push_messaging.dart` 만 import 한다. +/// +/// 기기 주소는 FID(Firebase 설치 ID — [register] 뒤 [onRegistered] 로 온다)다 — FCM 토큰 API 는 deprecated 라 쓰지 않는다. +/// 알림은 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다(앱 코드는 돌지 않는다). 앱이 앞에 있으면 띄우지 않고 [onForegroundMessage] 로만 온다. +abstract interface class PushMessaging { + /// 이 휴대폰에서 받을 수 있는가 — Firebase 를 띄웠다(설정 파일 없이 빌드했거나 Google Play 서비스가 없으면 거짓) + bool get isAvailable; + + /// 지금 알림 권한 + Future permission(); + + /// 알림 권한을 묻는다 — 안드로이드 13 이상에서 묻지 않았거나 한 번 거절했으면 시스템 창을 띄운다. 직원이 [알림 켜기]를 누른 뒤에만 부른다 + Future requestPermission(); + + /// FCM 에 이 앱을 등록한다 — 끝나면 [onRegistered] 로 FID 가 온다(값을 돌려주지 않는다) + Future register(); + + /// FCM 등록을 푼다 — 그 FID 로 보낸 알림은 오지 않는다 + Future unregister(); + + /// 등록된 FID — 등록할 때마다 · 바뀔 때마다 온다. **듣기 전에 온 값은 사라진다** — [register] 전에 듣는다 + Stream get onRegistered; + + /// 앱이 앞에 있을 때 온 알림 — 알림창에 띄우지 않는다 + Stream get onForegroundMessage; + + /// 앱이 뒤에 있을 때 누른 알림 + Stream get onOpened; + + /// 꺼져 있던 앱을 띄운 알림 — 한 번만 온다(다시 부르면 null) + Future initialOpen(); +} + +/// 받을 수 없는 휴대폰 — 아무것도 하지 않는다(main 이 Firebase 를 띄우지 못했을 때 · 시험의 기본값) +class UnavailablePushMessaging implements PushMessaging { + const UnavailablePushMessaging(); + + @override + bool get isAvailable => false; + + @override + Future permission() async => PushPermission.blocked; + + @override + Future requestPermission() async => PushPermission.blocked; + + @override + Future register() async {} + + @override + Future unregister() async {} + + @override + Stream get onRegistered => const Stream.empty(); + + @override + Stream get onForegroundMessage => + const Stream.empty(); + + @override + Stream get onOpened => const Stream.empty(); + + @override + Future initialOpen() async => null; +} + +/// 알림 받기 — main 이 Firebase 를 띄웠으면 `FirebasePushMessaging` 으로 덮어쓴다. 시험은 가짜로 +final Provider pushMessagingProvider = Provider( + (Ref ref) => const UnavailablePushMessaging(), +); diff --git a/flutter/lib/core/push/push_preferences.dart b/flutter/lib/core/push/push_preferences.dart new file mode 100644 index 0000000..3819026 --- /dev/null +++ b/flutter/lib/core/push/push_preferences.dart @@ -0,0 +1,144 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 이 휴대폰의 새 메일 알림 값 — 참 · 거짓과 FID 하나만 둔다(토큰 · 주소는 두지 않는다 — 보안 규칙 "토큰 저장") +@immutable +class PushPreferenceValues { + const PushPreferenceValues({ + this.enabled = true, + this.offerDismissed = false, + this.pendingUnregister = false, + this.lastFid, + this.serverRegistered = false, + }); + + /// 처음 값 — 켜짐(이 휴대폰에서 켜고 끈다. 직원이 바뀌어도 그대로) + static const PushPreferenceValues initial = PushPreferenceValues(); + + /// 새 메일 알림을 켰다 — 처음은 참 + final bool enabled; + + /// 받은편지함 위 권하기 줄을 닫았다 — 다시 보이지 않는다 + final bool offerDismissed; + + /// 로그아웃 · 세션 끝에 FCM 등록을 풀지 못했다 — 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시 푼다 + final bool pendingUnregister; + + /// 마지막으로 서버에 올린 FID — FCM 등록이 그대로인 동안만(풀 때 지운다). 다음 실행에서 등록 소식을 기다리지 않고 바로 올린다 + final String? lastFid; + + /// 지금 세션의 서버 줄이 있을 수 있다 — 꺼진 채 앱을 띄울 때마다 지우기(DELETE)를 보내지 않게 + final bool serverRegistered; + + @override + bool operator ==(Object other) => + other is PushPreferenceValues && + other.enabled == enabled && + other.offerDismissed == offerDismissed && + other.pendingUnregister == pendingUnregister && + other.lastFid == lastFid && + other.serverRegistered == serverRegistered; + + @override + int get hashCode => Object.hash( + enabled, + offerDismissed, + pendingUnregister, + lastFid, + serverRegistered, + ); +} + +/// 새 메일 알림 값을 두는 곳 — 포트다(앱은 `shared_preferences`, 시험은 메모리). +/// 읽기 · 쓰기가 실패해도 던지지 않는다 — 읽지 못하면 처음 값, 쓰지 못하면 이번 실행의 메모리 값으로 이어 간다 +abstract interface class PushPreferences { + Future load(); + + Future setEnabled(bool value); + + Future setOfferDismissed(bool value); + + Future setPendingUnregister(bool value); + + Future setLastFid(String? fid); + + Future setServerRegistered(bool value); +} + +/// `shared_preferences` 구현 — 앱을 지우면 함께 지워지고, 백업을 껐으니(AndroidManifest) 다른 기기로 옮겨지지 않는다 +class PrefsPushPreferences implements PushPreferences { + PrefsPushPreferences([SharedPreferencesAsync? prefs]) : _given = prefs; + + // 저장 키 — 바꾸면 이미 깐 앱의 설정이 처음 값으로 돌아간다 + static const String enabledKey = 'push_enabled'; + static const String offerDismissedKey = 'push_offer_dismissed'; + static const String pendingUnregisterKey = 'push_pending_unregister'; + static const String lastFidKey = 'push_last_fid'; + static const String serverRegisteredKey = 'push_server_registered'; + + final SharedPreferencesAsync? _given; + SharedPreferencesAsync? _prefs; + + // 처음 쓸 때 만든다 — 플러그인이 없는 곳(시험)에서 만들면 던진다 + SharedPreferencesAsync get _store => + _prefs ??= _given ?? SharedPreferencesAsync(); + + @override + Future load() async { + try { + final SharedPreferencesAsync prefs = _store; + return PushPreferenceValues( + enabled: await prefs.getBool(enabledKey) ?? true, + offerDismissed: await prefs.getBool(offerDismissedKey) ?? false, + pendingUnregister: await prefs.getBool(pendingUnregisterKey) ?? false, + lastFid: await prefs.getString(lastFidKey), + serverRegistered: await prefs.getBool(serverRegisteredKey) ?? false, + ); + } on Object { + return PushPreferenceValues.initial; + } + } + + @override + Future setEnabled(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(enabledKey, value), + ); + + @override + Future setOfferDismissed(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(offerDismissedKey, value), + ); + + @override + Future setPendingUnregister(bool value) => _write( + (SharedPreferencesAsync prefs) => + prefs.setBool(pendingUnregisterKey, value), + ); + + @override + Future setLastFid(String? fid) => _write( + (SharedPreferencesAsync prefs) => fid == null + ? prefs.remove(lastFidKey) + : prefs.setString(lastFidKey, fid), + ); + + @override + Future setServerRegistered(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(serverRegisteredKey, value), + ); + + Future _write( + Future Function(SharedPreferencesAsync prefs) write, + ) async { + try { + await write(_store); + } on Object { + // 쓰지 못했다 — 이번 실행은 메모리 값으로 이어 간다 + } + } +} + +/// 새 메일 알림 값을 두는 곳 — 시험은 메모리로 덮어쓴다 +final Provider pushPreferencesProvider = + Provider((Ref ref) => PrefsPushPreferences()); diff --git a/flutter/lib/core/push/push_registration.dart b/flutter/lib/core/push/push_registration.dart index a838722..e026a45 100644 --- a/flutter/lib/core/push/push_registration.dart +++ b/flutter/lib/core/push/push_registration.dart @@ -1,35 +1,91 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다). +/// 새 메일 알림의 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 등록을 쓴다). /// -/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와 -/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다: -/// - 로그인을 마치면(제한 없음) [register] -/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다. -/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다 -/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다). +/// 세션 노티파이어가 부른다(구현은 `push_controller.dart` 의 [PushController]): +/// - 메일을 쓸 수 있는 세션이 되면(제한 없는 로그인 · 되살리기 · 제한이 풀림) [register] +/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) · 세션이 끝나면(`AUTH_002`) [unregister] +/// - 세션에 제한이 새로 생기면(갱신 응답 — 비밀번호 만료 · 2단계 기한) [pause] abstract interface class PushRegistrar { + /// 메일을 쓸 수 있는 세션이 됐다 — 이 휴대폰이 켬이고 알림이 허락됐으면 서버에 등록한다(그 세션에서 처음 한 번) Future register(); - /// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다) + /// 세션이 끝났다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다. + /// 서버는 부르지 않는다(인증 토큰이 이미 없다 — 서버 줄은 logout · 세션 끝이 함께 지운다). 짧은 한도 안에서만 기다리고 실패는 삼킨다 Future unregister(); + + /// 세션에 제한이 생겼다 — 제한이 풀릴 때까지([register]) 서버를 부르지 않는다(서버도 제한된 세션에는 보내지 않는다) + void pause(); } -/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다 +/// 로그아웃 뒤 FCM 등록 풀기를 기다리는 최대 시간 — 넘으면 그만 기다린다(풀기는 뒤에서 이어지고, 못 풀면 다음 실행에 다시) const Duration kPushUnregisterTimeout = Duration(seconds: 5); -/// 지금의 구현 — 아무것도 하지 않는다 -class NoopPushRegistrar implements PushRegistrar { - const NoopPushRegistrar(); +/// 서버에 알리는 플랫폼 — iOS 를 붙일 때 `ios` 를 더한다(계약 "앱 새 메일 알림") +const String kPushPlatform = 'android'; - @override - Future register() async {} +/// 판을 읽지 못한 기기(거의 없다)가 보내는 앱 판 — 서버 칸의 모양(`숫자.숫자.숫자`)에 맞춘 값 +const String kUnknownAppVersion = '0.0.0'; - @override - Future unregister() async {} +/// 이 세션의 새 메일 알림 등록 — 계약 1절 "앱 새 메일 알림 · 앱 설정" +/// +/// - `PUT /api/push-registrations/current` `{ fid, platform, appVersion }` → 200(응답은 쓰지 않는다). 서버는 세션(sid)마다 줄 하나를 두고 +/// 같은 FID 의 다른 줄(다른 세션 · 다른 직원)을 지운다. +/// - `DELETE /api/push-registrations/current` → 204(없어도 204) +/// - `DELETE /api/push-registrations/current/unseen` → 204 — "봤음"(서버가 N 을 처음부터 센다) +/// +/// 저절로 하는 맞추기는 알림을 끄고([silent]), 직원이 켜고 끌 때만 알린다(인터셉터의 사전 문구). FID 는 로그에 남기지 않는다. +class PushRegistrationRepository { + const PushRegistrationRepository(this._dio); + + final Dio _dio; + + /// 등록한다 — 실패는 `DioException` + Future put({ + required String fid, + required String appVersion, + required bool silent, + }) async { + await _dio.put( + ApiPaths.pushRegistration, + data: { + 'fid': fid, + 'platform': kPushPlatform, + 'appVersion': appVersion, + }, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// 등록을 지운다 — 실패는 `DioException` + Future delete({required bool silent}) async { + await _dio.delete( + ApiPaths.pushRegistration, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// "봤음" — 알리지 않는다. 실패는 `DioException` + Future markSeen() async { + await _dio.delete( + ApiPaths.pushRegistrationUnseen, + options: ApiOptions.silent(), + ); + } } -/// 푸시 기기 등록 +/// 새 메일 알림 등록 API +final Provider pushRegistrationRepositoryProvider = + Provider( + (Ref ref) => PushRegistrationRepository(ref.watch(dioProvider)), + ); + +/// 세션 노티파이어가 부르는 기기 등록 — 새 메일 알림 컨트롤러다(시험은 기록하는 가짜로 덮어쓴다) final Provider pushRegistrarProvider = Provider( - (Ref ref) => const NoopPushRegistrar(), + (Ref ref) => ref.watch(pushControllerProvider.notifier), ); diff --git a/flutter/lib/core/push/push_state.dart b/flutter/lib/core/push/push_state.dart new file mode 100644 index 0000000..2e2b3c4 --- /dev/null +++ b/flutter/lib/core/push/push_state.dart @@ -0,0 +1,250 @@ +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:flutter/foundation.dart'; + +/// 안드로이드 13(API 33)부터 알림 권한을 묻는다 — 권하기 줄은 그 판부터 보인다 +const int kPushOfferMinSdk = 33; + +/// 서버가 새 메일 알림을 보낼 수 있는가 — `GET /api/app-config` 의 `pushAvailable` +enum PushServer { + /// 아직 모른다 · 받지 못했다 — 쓸 수 있는 것으로 본다(막지 않는다) + unknown, + available, + + /// 보낼 수 없다(FCM 키가 없다 · 그 경로가 없는 옛 서버) + unavailable, +} + +/// 직원이 켜고 끄는 중 +enum PushChange { turningOn, turningOff } + +/// 새 메일 알림 상태 — 설정 › 새 메일 알림 줄과 받은편지함 위 권하기 줄이 본다([pushRowStatusOf] · [showsPushOffer]) +@immutable +class PushState { + const PushState({ + this.loaded = false, + this.deviceSupported = false, + this.server = PushServer.unknown, + this.sessionReady = false, + this.enabled = true, + this.permission, + this.channelEnabled = true, + this.sdkInt, + this.registered = false, + this.change, + this.offerDismissed = false, + }); + + /// 이 휴대폰의 값(켬 · 권하기 줄 닫음)과 알림 권한을 읽었다 + final bool loaded; + + /// 이 휴대폰에서 받을 수 있다 — Firebase 를 띄웠다 + final bool deviceSupported; + + final PushServer server; + + /// 메일을 쓸 수 있는 세션이다(제한 없는 로그인) — 세션 노티파이어의 register · unregister · pause + final bool sessionReady; + + /// 이 휴대폰에서 켰다 — 처음은 켜짐(직원이 바뀌어도 그대로) + final bool enabled; + + /// 알림 권한 — 아직 읽지 않았으면 null + final PushPermission? permission; + + /// 새 메일 채널이 켜져 있다(휴대폰 설정에서 채널만 끌 수 있다) + final bool channelEnabled; + + /// 안드로이드 판(API) — 모르면 null + final int? sdkInt; + + /// 이 세션의 서버 등록이 있다(이번 실행에서 확인했다) + final bool registered; + + /// 직원이 켜거나 끄는 중 + final PushChange? change; + + /// 받은편지함 위 권하기 줄을 닫았다 + final bool offerDismissed; + + PushState copyWith({ + bool? loaded, + bool? deviceSupported, + PushServer? server, + bool? sessionReady, + bool? enabled, + PushPermission? permission, + bool? channelEnabled, + int? sdkInt, + bool? registered, + PushChange? change, + bool clearChange = false, + bool? offerDismissed, + }) { + return PushState( + loaded: loaded ?? this.loaded, + deviceSupported: deviceSupported ?? this.deviceSupported, + server: server ?? this.server, + sessionReady: sessionReady ?? this.sessionReady, + enabled: enabled ?? this.enabled, + permission: permission ?? this.permission, + channelEnabled: channelEnabled ?? this.channelEnabled, + sdkInt: sdkInt ?? this.sdkInt, + registered: registered ?? this.registered, + change: clearChange ? null : (change ?? this.change), + offerDismissed: offerDismissed ?? this.offerDismissed, + ); + } + + @override + bool operator ==(Object other) => + other is PushState && + other.loaded == loaded && + other.deviceSupported == deviceSupported && + other.server == server && + other.sessionReady == sessionReady && + other.enabled == enabled && + other.permission == permission && + other.channelEnabled == channelEnabled && + other.sdkInt == sdkInt && + other.registered == registered && + other.change == change && + other.offerDismissed == offerDismissed; + + @override + int get hashCode => Object.hash( + loaded, + deviceSupported, + server, + sessionReady, + enabled, + permission, + channelEnabled, + sdkInt, + registered, + change, + offerDismissed, + ); +} + +/// 이 휴대폰이 알림을 받고 싶은가 — 켬 ∧ 권한 ∧ 새 메일 채널 +bool pushWanted(PushState state) => + state.enabled && + state.permission == PushPermission.granted && + state.channelEnabled; + +/// 서버와 맞출 수 있는가 — 값을 읽었고 ∧ 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있고(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션 +bool pushReachable(PushState state) => + state.loaded && + state.deviceSupported && + state.server != PushServer.unavailable && + state.sessionReady; + +/// 설정 › 새 메일 알림 줄의 모습 — 보드 `App1Push` ④ · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘" 의 표 +enum PushRowStatus { + /// 아직 읽는 중 — 스위치를 누를 수 없다 + loading, + + /// 켜짐 + on, + + /// 꺼짐 — 직원이 껐다 + off, + + /// 꺼짐 · 허락 전 — 켜면 휴대폰이 허용할지 묻는다(안드로이드 13 이상, 묻지 않음 · 한 번 거절) + notAsked, + + /// 휴대폰 설정에서 이 앱의 알림이 막혔다 — 스위치는 누를 수 없고 [설정 열기] + blocked, + + /// 휴대폰 설정에서 새 메일 채널만 꺼졌다 — 스위치는 누를 수 없고 [설정 열기] + channelOff, + + /// 지금은 쓸 수 없다(서버가 보낼 수 없다) — 스위치는 누를 수 없다 + unavailable, + + /// 이 휴대폰에서는 받을 수 없다(Firebase 를 띄우지 못함 · Google Play 서비스 없음) — 스위치는 누를 수 없다 + unsupported, + + /// 켜는 중 — 스위치는 켬 · 바쁨 + turningOn, + + /// 끄는 중 — 스위치는 끔 · 바쁨 + turningOff, +} + +/// 설정 줄의 모습 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 읽는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔 +PushRowStatus pushRowStatusOf(PushState state) { + if (!state.loaded) return PushRowStatus.loading; + if (!state.deviceSupported) return PushRowStatus.unsupported; + if (state.server == PushServer.unavailable) return PushRowStatus.unavailable; + switch (state.change) { + case PushChange.turningOn: + return PushRowStatus.turningOn; + case PushChange.turningOff: + return PushRowStatus.turningOff; + case null: + break; + } + final PushPermission? permission = state.permission; + if (permission == null) return PushRowStatus.loading; + switch (permission) { + case PushPermission.blocked: + return PushRowStatus.blocked; + case PushPermission.notAsked: + case PushPermission.denied: + return PushRowStatus.notAsked; + case PushPermission.granted: + break; + } + if (!state.channelEnabled) return PushRowStatus.channelOff; + return state.enabled ? PushRowStatus.on : PushRowStatus.off; +} + +/// 받은편지함 위 권하기 줄을 보이는가 — 안드로이드 13 이상 · 아직 묻지 않음 · 켬 · 서버가 보낼 수 있음 · 받을 수 있는 휴대폰 · +/// 메일을 쓸 수 있는 세션 · 닫은 적 없음 · 켜는 중이 아님 +bool showsPushOffer(PushState state) => + state.loaded && + state.deviceSupported && + state.server != PushServer.unavailable && + state.sessionReady && + state.enabled && + !state.offerDismissed && + state.change == null && + state.permission == PushPermission.notAsked && + (state.sdkInt ?? 0) >= kPushOfferMinSdk; + +/// [알림 켜기] · 설정 스위치를 켠 결과 — 화면이 글을 고른다(보드 `App1Push` ③ · ④) +enum PushTurnOnResult { + /// 켰다(서버 등록까지 끝났다) + on, + + /// 시스템 창에서 [허용 안 함] + denied, + + /// 휴대폰 설정에서만 켤 수 있다(창이 뜨지 않았다) + blocked, + + /// 새 메일 채널만 꺼져 있다 + channelOff, + + /// 지금 쓸 수 없다 · 받을 수 없는 휴대폰 · 세션이 없다 + unavailable, + + /// 서버 등록이 실패했다 — 그 코드의 사전 문구를 이미 알렸다(이 휴대폰의 값을 끔으로 두어 스위치가 꺼진 채로 돌아간다 — 저절로 다시 하지 않는다) + failed, + + /// 이미 켜거나 끄는 중 + busy, +} + +/// 설정 스위치를 끈 결과 +enum PushTurnOffResult { + /// 껐다 + off, + + /// 서버에서 지우지 못했다 — 그 코드의 사전 문구를 이미 알렸다(스위치는 되돌린다) + failed, + + /// 이미 켜거나 끄는 중 + busy, +} diff --git a/flutter/lib/core/push/push_target.dart b/flutter/lib/core/push/push_target.dart index 49d1385..7fb7ee1 100644 --- a/flutter/lib/core/push/push_target.dart +++ b/flutter/lib/core/push/push_target.dart @@ -1,14 +1,15 @@ import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; -/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다. +/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과(있으면) 항목 id 만** 싣는다. /// /// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림"). /// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다. +/// 새 메일 알림의 데이터는 `{ module: 'mail', kind: 'new-mail' }` 이다(id 없음 — 늘 받은편지함. 종류는 `push_messaging.dart` 의 `PushMessage`). class PushTarget { const PushTarget({required this.module, this.itemId}); - /// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null. + /// 알림 데이터(`{ module: 'mail', … }` — 항목이 있는 알림은 `id: '…'` 도)를 읽는다. 모듈 이름을 모르면 null. static PushTarget? fromData(Map data) { final Object? name = data[moduleKey]; final AppModuleId? module = AppModuleId.fromName( diff --git a/flutter/lib/core/push/push_texts.dart b/flutter/lib/core/push/push_texts.dart new file mode 100644 index 0000000..d989bd4 --- /dev/null +++ b/flutter/lib/core/push/push_texts.dart @@ -0,0 +1,47 @@ +/// 새 메일 알림의 화면 글 — 받은편지함 위 권하기 줄(메일 모듈)과 설정 › 새 메일 알림 줄(설정 모듈)이 함께 쓴다. +/// +/// 앱 보드 `App1Push`(2026-11-07 확정)의 글 그대로다 — 글자 하나까지(디자인 README 「화면 문구 — 사용자에게 말하듯」). +/// 휴대폰 설정에 보이는 채널 이름 · 설명은 안드로이드 `res/values/strings.xml` 에 있다(보드 ⑫). +abstract final class PushTexts { + // ── ① 받은편지함 · 권하기 줄 ── + /// 줄의 영역 이름(화면 낭독기) + static const String offerRegion = '새 메일 알림 안내'; + static const String offerMessage = + '새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.'; + static const String offerEnable = '알림 켜기'; + + /// [✕]의 이름(화면 낭독기) + static const String offerDismiss = '닫기'; + + // ── ③ 휴대폰이 묻는 창에서 고른 뒤 ── + static const String enabled = '새 메일 알림을 켰습니다.'; + static const String deniedLater = '나중에 [설정] 탭에서 새 메일 알림을 켤 수 있습니다.'; + + /// 창이 뜨지 않을 만큼 이미 막혀 있을 때 — 알림에 [설정 열기] + static const String blockedNotice = '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다.'; + + /// 새 메일 알림 카테고리만 꺼져 있을 때 — 알림에 [설정 열기](보드 ④ 의 같은 상태 글의 앞 문장) + static const String channelOffNotice = '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다.'; + + // ── ④ 설정 › 새 메일 알림 줄 ── + static const String rowLabel = '새 메일 알림'; + static const String rowDescription = '새 메일이 오면 휴대폰 알림으로 알려 드립니다.'; + static const String rowNotAsked = '켜면 휴대폰이 알림을 허용할지 묻습니다. 그때 [허용]을 눌러 주세요.'; + static const String rowBlocked = + '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다. [설정 열기]를 눌러 알림을 켜 주세요.'; + static const String rowChannelOff = + '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다. [설정 열기]를 눌러 켜 주세요.'; + static const String rowUnavailable = + '지금은 새 메일 알림을 쓸 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.'; + static const String rowUnsupported = + '이 휴대폰에서는 새 메일 알림을 받을 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.'; + + /// 켜는 중 · 끄는 중(화면 낭독기가 읽는다) + static const String turningOn = '켜는 중'; + static const String turningOff = '끄는 중'; + + // ── [설정 열기] ── + static const String openSettings = '설정 열기'; + static const String openSettingsFailed = + '휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.'; +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 8a0784b..53c6d40 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -1,10 +1,13 @@ +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; import 'package:arasystem_app/core/router/app_routes.dart'; import 'package:arasystem_app/core/router/app_shell.dart'; import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:arasystem_app/core/router/push_navigation.dart'; import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart'; import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; @@ -22,15 +25,17 @@ final Provider> appModulesProvider = Provider>( (Ref ref) => kAppModules, ); -/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다. +/// 앱 라우터 — 세션 · 업데이트 여부(앱 설정의 최소 판)가 바뀔 때마다 redirect 를 다시 돌린다. /// -/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다. +/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)과 업데이트 화면(`/update`)은 탭 막대 밖(root navigator)이다. /// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다). /// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다. /// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException). +/// - 누른 알림은 [connectPushNavigation] 이 옮긴다(새 메일 알림 컨트롤러도 그때 띄운다). /// /// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다. -/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). +/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). 업데이트 화면은 보드 `App1Push` ⑩ 의 화면이다 +/// (`features/app_update/` — 모듈이 아니다). final Provider routerProvider = Provider((Ref ref) { final List modules = ref.watch(appModulesProvider); final List enabled = enabledAppModules(modules); @@ -42,22 +47,28 @@ final Provider routerProvider = Provider((Ref ref) { settingsSections: moduleSettingsSections(moduleRoutes), ); - final _SessionChanges sessionChanges = _SessionChanges(); - ref.listen>( - sessionProvider, - (AsyncValue? previous, AsyncValue next) => - sessionChanges.notify(), - ); + final _RouteChanges routeChanges = _RouteChanges(); + ref + ..listen>( + sessionProvider, + (AsyncValue? previous, AsyncValue next) => + routeChanges.notify(), + ) + ..listen( + appUpdateRequiredProvider, + (bool? previous, bool next) => routeChanges.notify(), + ); final GoRouter router = GoRouter( navigatorKey: rootNavigatorKey, initialLocation: AppRoutes.start, - refreshListenable: sessionChanges, + refreshListenable: routeChanges, redirect: (BuildContext context, GoRouterState state) => resolveSessionRedirect( ref.read(sessionProvider), state.uri.path, modules: modules, + updateRequired: ref.read(appUpdateRequiredProvider), ), // 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로 onException: (BuildContext context, GoRouterState state, GoRouter router) => @@ -69,6 +80,8 @@ final Provider routerProvider = Provider((Ref ref) { _loginRoute(AppRoutes.passwordChange, const PasswordChangePage()), _loginRoute(AppRoutes.twoFactorSetup, const TwoFactorSetupPage()), _loginRoute(AppRoutes.restricted, const RestrictedLoginPage()), + // 업데이트 — 뒤로 가기는 앱을 닫는다(go 로만 오니 쌓인 화면이 없다) + _loginRoute(AppRoutes.update, const AppUpdatePage()), StatefulShellRoute.indexedStack( builder: ( @@ -84,9 +97,11 @@ final Provider routerProvider = Provider((Ref ref) { ], ); + connectPushNavigation(ref, router, modules: modules); + ref.onDispose(() { router.dispose(); - sessionChanges.dispose(); + routeChanges.dispose(); }); return router; }); @@ -107,7 +122,7 @@ GoRoute _loginRoute(String path, Widget page) { ); } -/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable) -class _SessionChanges extends ChangeNotifier { +/// 세션 · 업데이트 여부가 바뀐 것을 라우터에 알린다 (refreshListenable) +class _RouteChanges extends ChangeNotifier { void notify() => notifyListeners(); } diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 808e4c3..4355327 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -20,4 +20,7 @@ abstract final class AppRoutes { /// 제한된 세션 — 앱이 모르는 제한 static const String restricted = '/login/restricted'; + + /// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도) + static const String update = '/update'; } diff --git a/flutter/lib/core/router/push_navigation.dart b/flutter/lib/core/router/push_navigation.dart new file mode 100644 index 0000000..ae30eca --- /dev/null +++ b/flutter/lib/core/router/push_navigation.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 누른 알림을 라우터로 옮긴다 — 라우터 프로바이더가 만들 때 한 번 건다(core/push 는 라우터를 모른다). +/// +/// - 누른 알림은 [PushEvents] 가 값으로 들고 있다가 여기서 가져간다 — 앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않는다. +/// - 갈 곳은 [locationForPush] — 새 메일 알림은 늘 메일 탭 받은편지함이다(메일 탭의 다른 메일함 · 검색 · 읽기 · 쓰기는 닫힌다. +/// 쓰던 메일은 쓰기 화면이 내려가며 임시보관함에 마저 저장한다). +/// - 로그아웃 상태 · 되살리는 중 · 업데이트해야 하면 redirect 가 그 화면으로 돌려보낸다 — 기다려 둔 갈 곳은 두지 않는다 +/// (로그인 뒤 첫 화면이 받은편지함이다. id 가 있는 알림이 생기면 그때 더한다). +/// - 다른 직원이 로그인해 있어도 그 직원의 받은편지함을 연다 — 알림에 메일 · 직원을 싣지 않아 새는 것이 없다. +/// - 새 메일 알림 컨트롤러도 여기서 띄운다 — 앱을 띄우자마자 앞에서 받은 알림 · 누른 알림을 듣게. +void connectPushNavigation( + Ref ref, + GoRouter router, { + List modules = kAppModules, +}) { + void openPending() { + if (!ref.mounted) return; + final PushMessage? opened = ref + .read(pushEventsProvider.notifier) + .takeOpened(); + if (opened == null) return; + router.go(locationForPush(opened.target, moduleRoutesOf, modules: modules)); + } + + ref + ..listen(pushEventsProvider, ( + PushEvent? previous, + PushEvent? next, + ) { + if (next != null && next.opened) openPending(); + }) + ..listen(pushControllerProvider, (PushState? _, PushState _) {}); + // 라우터보다 먼저 온 누른 알림 — 라우터를 만든 뒤에 옮긴다(만드는 중에는 옮기지 않는다) + scheduleMicrotask(openPending); +} diff --git a/flutter/lib/core/router/session_redirect.dart b/flutter/lib/core/router/session_redirect.dart index f10dfbe..5ca0cbf 100644 --- a/flutter/lib/core/router/session_redirect.dart +++ b/flutter/lib/core/router/session_redirect.dart @@ -10,16 +10,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// /// | 세션 | 화면 | /// | --- | --- | +/// | (세션과 상관없이) 앱을 업데이트해야 함([updateRequired]) | 업데이트 — 로그인 전 화면에서도(옛 앱이 로그인 API 를 잘못 부르지 않게) | /// | 되살리는 중 · 되살리기 실패 | 시작 | /// | 로그아웃 | 로그인 | /// | 코드 단계 | 코드 | /// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 | /// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 | +/// +/// 업데이트가 풀리면(서버가 최소 판을 내림) 업데이트 화면은 세션이 고른 화면으로 돌아간다(그 경로는 로그인 전 화면도 모듈도 아니다). String? resolveSessionRedirect( AsyncValue session, String location, { List modules = kAppModules, + bool updateRequired = false, }) { + if (updateRequired) return _stayOr(location, AppRoutes.update); final SessionState? state = session.value; if (state == null) return _stayOr(location, AppRoutes.start); diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index c51f380..8c09a32 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -132,4 +132,11 @@ abstract final class AppColors { /// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막 static const Color backdrop = Color(0x520B3640); + + // ── 앱 보드에서 정한 색 (`App1Shell` · `App1Push` — 웹 토큰에 없다) ── + /// 누를 수 없는 스위치의 손잡이 + static const Color switchThumbDisabled = Color(0xFFC5CBD3); + + /// 둥근 진행 표시(켜는 중 · 끄는 중)의 바탕 고리 + static const Color progressRingTrack = Color(0xFFD5ECEE); } diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index f093048..cdf5dc3 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -209,4 +209,24 @@ abstract final class AppSize { /// 시트의 [찾기]처럼 채운 주 단추의 최소 폭 static const double primaryActionMinWidth = 120; + + // ── 앱 보드에서 정한 크기 (`App1Shell` · `App1Push` — 2026-11-07 확정) ── + /// 설정 화면의 두 줄 칸(내 정보 · 새 메일 알림)의 최소 높이 + static const double settingsRowMinHeight = 68; + + /// 설정 화면의 한 줄 칸(앱 버전 · 오픈소스 라이선스)의 최소 높이 + static const double settingsLineMinHeight = 52; + + /// 설정 화면 내 정보의 동그란 사진 + static const double settingsAvatar = 48; + + /// 스위치의 누르는 곳 — 보이는 바는 52×32(Material 3) + static const double switchTouchWidth = 64; + + /// 스위치 바의 테두리 + static const double switchOutlineWidth = 2; + + /// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께 + static const double progressRing = 22; + static const double progressRingStroke = 2.6; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 0f4e81f..5ff7e50 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -98,6 +98,7 @@ abstract final class AppTheme { ), ), navigationBarTheme: _navigationBarTheme(scheme, textTheme), + switchTheme: _switchTheme(scheme), // 아래에서 올라오는 시트(옮기기 · 조건으로 찾기)의 가림막 — 대화상자 가림막 토큰(`--color-backdrop`). Flutter 기본은 검정 54% bottomSheetTheme: const BottomSheetThemeData( modalBarrierColor: AppColors.backdrop, @@ -147,6 +148,37 @@ abstract final class AppTheme { ); } + /// 스위치 — 앱 보드 `App1Shell` · `App1Push`(52×32 바 · 테두리 2 · 손잡이 켜짐 24 · 꺼짐 16 은 Material 3 그대로). + /// 켜짐은 청록 바에 흰 손잡이, 꺼짐은 흰 바에 회색 테두리 · 손잡이, 누를 수 없으면 옅은 회색(흐리게 하지 않는다) + static SwitchThemeData _switchTheme(ColorScheme scheme) { + bool disabled(Set states) => + states.contains(WidgetState.disabled); + bool on(Set states) => states.contains(WidgetState.selected); + return SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.switchThumbDisabled; + return on(states) ? scheme.onPrimary : scheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.surfaceMuted; + return on(states) ? scheme.primary : scheme.surface; + }), + trackOutlineColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.borderStrong; + return on(states) ? scheme.primary : scheme.outline; + }), + trackOutlineWidth: const WidgetStatePropertyAll( + AppSize.switchOutlineWidth, + ), + ); + } + static OutlineInputBorder _inputBorder( Color color, { double width = AppSize.borderWidth, diff --git a/flutter/lib/features/app_update/data/app_store_launcher.dart b/flutter/lib/features/app_update/data/app_store_launcher.dart new file mode 100644 index 0000000..6853542 --- /dev/null +++ b/flutter/lib/features/app_update/data/app_store_launcher.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef StoreLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소) +const String kPlayStoreWebDetails = + 'https://play.google.com/store/apps/details'; + +/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면). +/// +/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로. +/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 — +/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다 +class AppStoreLauncher { + const AppStoreLauncher({StoreLaunch launch = _launchExternal}) + : _launch = launch; + + final StoreLaunch _launch; + + /// 열었으면 참 + Future open(String packageName) async { + final Uri market = Uri( + scheme: 'market', + host: 'details', + queryParameters: {'id': packageName}, + ); + if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) { + return true; + } + final Uri web = Uri.parse( + kPlayStoreWebDetails, + ).replace(queryParameters: {'id': packageName}); + return _tryLaunch(web, LaunchMode.externalApplication); + } + + Future _tryLaunch(Uri url, LaunchMode mode) async { + try { + return await _launch(url, mode); + } on Object { + return false; + } + } +} + +/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다 +final Provider appStoreLauncherProvider = + Provider((Ref ref) => const AppStoreLauncher()); diff --git a/flutter/lib/features/app_update/presentation/app_update_texts.dart b/flutter/lib/features/app_update/presentation/app_update_texts.dart new file mode 100644 index 0000000..bc27ef6 --- /dev/null +++ b/flutter/lib/features/app_update/presentation/app_update_texts.dart @@ -0,0 +1,22 @@ +/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`. +/// +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +abstract final class AppUpdateTexts { + static const String message = + '지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.'; + static const String openStore = 'Play 스토어 열기'; + + /// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0" + static String currentVersion(String version) => '지금 쓰는 버전 $version'; + + /// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처 + static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?'; + + /// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴) + static const String supportFallback = + 'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.'; + + /// Play 스토어를 열지 못했을 때(알림) + static const String openStoreFailed = + 'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.'; +} diff --git a/flutter/lib/features/app_update/presentation/pages/app_update_page.dart b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart new file mode 100644 index 0000000..d63e1ed --- /dev/null +++ b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart'; +import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다 +/// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`). +/// +/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄. +/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다. +/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고, +/// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다. +class AppUpdatePage extends ConsumerStatefulWidget { + const AppUpdatePage({super.key}); + + @override + ConsumerState createState() => _AppUpdatePageState(); +} + +class _AppUpdatePageState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + } + + Future _openStore() async { + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + final String? packageName = ref.read(appPackageNameProvider); + final bool opened = + packageName != null && + await ref.read(appStoreLauncherProvider).open(packageName); + if (opened) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? version = ref.watch(appVersionProvider); + return LoginBrandFrame( + introduction: false, + contentTopPadding: AppSpacing.s32, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s10, + children: [ + Semantics( + header: true, + child: Text( + ScreenTitles.update, + style: theme.textTheme.headlineSmall, + ), + ), + KeepAllText( + AppUpdateTexts.message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + FilledButton( + onPressed: () => unawaited(_openStore()), + child: const Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s8, + children: [ + Text(AppUpdateTexts.openStore), + Icon(Icons.open_in_new, size: AppSize.iconSmall), + ], + ), + ), + if (version != null) + Text( + AppUpdateTexts.currentVersion(version), + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: LoginSupportLine( + question: AppUpdateTexts.supportQuestion, + fallback: AppUpdateTexts.supportFallback, + contact: ref.watch(supportContactProvider), + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index 8ddd681..1c096aa 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -319,8 +319,8 @@ class SessionNotifier extends AsyncNotifier { ); } - /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. - /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. + /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지운다. 그 뒤 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)와 서버 logout 을 + /// 함께 보낸다(서로 기다리지 않는다). 서버에 닿지 못해도 이 기기는 로그아웃 상태다. 알림 서버의 기기 줄은 부르지 않는다 — 서버 logout 이 세션과 함께 지운다. Future signOut({SignOutReason reason = SignOutReason.none}) async { _advanceEpoch(); _secret.clear(); @@ -424,6 +424,8 @@ class SessionNotifier extends AsyncNotifier { final String? code = apiErrorCodeOf(error); if (code == 'AUTH_001' || code == 'AUTH_002') { await _clearQuietly(); + // 앱이 꺼진 사이 세션이 끝났다 — 이 기기의 알림도 멈춘다(세션 끝과 같다) + unawaited(_unregisterPushQuietly()); return const SignedOut(); } // 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다 @@ -503,7 +505,11 @@ class SessionNotifier extends AsyncNotifier { // 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다 // (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn) if (!_usesHeldPassword(info.restriction)) _secret.clear(); - if (info.restriction != null) return; + if (info.restriction != null) { + // 쓰던 세션에 제한이 새로 생겼다(갱신 응답 — 비밀번호 만료 · 2단계 기한) — 풀릴 때까지 알림 서버를 부르지 않는다 + if (wasReady) _push.pause(); + return; + } if (!wasReady) unawaited(_registerPush()); } @@ -520,11 +526,13 @@ class SessionNotifier extends AsyncNotifier { } } + /// 이 기기의 새 메일 알림을 멈춘다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다(짧은 한도 · 실패는 삼킨다). + /// 서버의 기기 줄은 logout · 세션 끝이 세션과 함께 지운다 Future _unregisterPushQuietly() async { try { await _push.unregister().timeout(kPushUnregisterTimeout); } on Object { - // 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다 + // 못 푼 등록은 알림 컨트롤러가 기억해 다음 실행에 다시 푼다 } } @@ -556,6 +564,8 @@ class SessionNotifier extends AsyncNotifier { state = const AsyncData( SignedOut(reason: SignOutReason.expired), ); + // 이 기기의 알림을 멈춘다 — 서버 줄은 세션을 끝낸 서버가 지운다 + unawaited(_unregisterPushQuietly()); case SessionRefreshed(:final Map body): // 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다) if (current is! SignedIn) return; diff --git a/flutter/lib/features/auth/presentation/widgets/login_frame.dart b/flutter/lib/features/auth/presentation/widgets/login_frame.dart index d7a7ce7..29ecf83 100644 --- a/flutter/lib/features/auth/presentation/widgets/login_frame.dart +++ b/flutter/lib/features/auth/presentation/widgets/login_frame.dart @@ -100,21 +100,33 @@ ThemeData loginThemeOf(ThemeData base) { ); } -/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다 +/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다. +/// 업데이트 화면(앱 보드 `App1Push` ⑩)도 이 틀을 쓴다 — 띠에 로고만([introduction] 거짓) · 내용 위 32 class LoginBrandFrame extends StatelessWidget { - const LoginBrandFrame({required this.children, super.key}); + const LoginBrandFrame({ + required this.children, + this.introduction = true, + this.contentTopPadding = AppSpacing.s28, + super.key, + }); /// 브랜드 띠 아래의 내용 — 사이 20 final List children; + /// 띠에 큰 글 · 소개 글을 보이는가 — 거짓이면 로고만 + final bool introduction; + + /// 띠 아래 내용의 위 여백 + final double contentTopPadding; + @override Widget build(BuildContext context) { return _LoginScaffold( top: const _StatusBarStrip(), body: _LoginScroll( children: [ - const _BrandBand(), - _LoginColumn(topPadding: AppSpacing.s28, children: children), + _BrandBand(introduction: introduction), + _LoginColumn(topPadding: contentTopPadding, children: children), ], ), ); @@ -264,9 +276,11 @@ class _FormWidth extends StatelessWidget { } } -/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글 +/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글([introduction] 이 거짓이면 로고만) class _BrandBand extends StatelessWidget { - const _BrandBand(); + const _BrandBand({required this.introduction}); + + final bool introduction; @override Widget build(BuildContext context) { @@ -291,26 +305,27 @@ class _BrandBand extends StatelessWidget { width: LoginLayout.logoWidth, semanticLabel: kAppName, ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.s6, - children: [ - KeepAllText( - LoginTexts.brandHeadline, - // 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand) - style: theme.textTheme.headlineSmall?.copyWith( - color: theme.colorScheme.onPrimary, + if (introduction) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + LoginTexts.brandHeadline, + // 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand) + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), ), - ), - KeepAllText( - LoginTexts.brandLead, - style: theme.textTheme.bodyMedium?.copyWith( - color: extra.onBrandMuted, - height: AppTypography.lineHeightRelaxed, + KeepAllText( + LoginTexts.brandLead, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.onBrandMuted, + height: AppTypography.lineHeightRelaxed, + ), ), - ), - ], - ), + ], + ), ], ), ), diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index b6ac4ae..f41be4f 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -22,6 +22,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_state import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart'; import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -34,6 +35,7 @@ import 'package:go_router/go_router.dart'; /// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다. /// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인). /// - 화면이 보이는지(다른 탭 · 전체 화면 · 위에 쌓인 메일함에 가렸는지)는 TickerMode 로 안다. +/// - 받은편지함은 위 막대 아래에 새 메일 알림 권하기 줄을 둔다(보일 때만 — [PushOfferLine]). 고르는 동안은 숨긴다. class MailListPage extends ConsumerStatefulWidget { const MailListPage({this.mailboxId, super.key}); @@ -197,22 +199,33 @@ class _MailListPageState extends ConsumerState { hidden: selecting, child: Scaffold( appBar: appBar, - body: _MailListBody( - list: list, - role: role, - title: title, - mailboxesReady: mailboxesReady, - mailboxesFailure: mailboxes.failure, - unknownMailbox: unknownMailbox, - onRefresh: _controller.refresh, - onRetry: () => unawaited(_controller.retry()), - onRetryMailboxes: () => - unawaited(ref.read(mailboxesProvider.notifier).reload()), - onLeave: inboxRoute ? null : () => context.go(MailRoutes.home), - onLoadMore: () => unawaited(_controller.loadMore()), - onRowTap: (MessageSummary message) => - selecting ? _toggle(message) : _open(message, role), - onRowSelect: _toggle, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (role == MailboxRole.inbox && !selecting) + const PushOfferLine(), + Expanded( + child: _MailListBody( + list: list, + role: role, + title: title, + mailboxesReady: mailboxesReady, + mailboxesFailure: mailboxes.failure, + unknownMailbox: unknownMailbox, + onRefresh: _controller.refresh, + onRetry: () => unawaited(_controller.retry()), + onRetryMailboxes: () => + unawaited(ref.read(mailboxesProvider.notifier).reload()), + onLeave: inboxRoute + ? null + : () => context.go(MailRoutes.home), + onLoadMore: () => unawaited(_controller.loadMore()), + onRowTap: (MessageSummary message) => + selecting ? _toggle(message) : _open(message, role), + onRowSelect: _toggle, + ), + ), + ], ), floatingActionButton: MailComposeArea( key: const ValueKey('mail-compose-area'), diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index eaba777..25290a7 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -47,6 +47,8 @@ const double _bodyLineHeight = 1.7; /// 올리는 중인 첨부가 있으면 먼저 따로 묻는다("올리는 중인 첨부가 있습니다") — [나가기]면 올리던 파일을 빼고 위 차례대로, /// 묻는 사이 모두 다 올렸으면 그대로 떠난다(올리지 못한 파일이 생겼으면 답을 기다린다). /// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다. +/// - 스스로 닫지 않고 내려가면(알림을 눌러 받은편지함으로 · 업데이트 화면으로) 바꾼 것을 끝까지 저장하고 알린다("임시보관함에 저장했습니다." +/// · 못 했으면 그 코드의 사전 문구). 세션이 끝나 내려간 것은 저장하지 않는다. class MessageComposePage extends ConsumerStatefulWidget { const MessageComposePage({required this.intent, this.prefill, super.key}); @@ -91,6 +93,9 @@ class _MessageComposePageState extends ConsumerState { bool _closing = false; bool _announceSaved = false; + /// 바깥 이동(알림을 눌러 받은편지함으로 · 업데이트 화면)이 잃을 것이 있는 이 화면을 내렸다 — 내려가며 마저 저장한다(detach) + bool _closedOutside = false; + /// 나가기 확인 창이 열려 있다 — 그사이 저장이 끝나면 창을 닫고 떠난다 bool _askingLeave = false; bool _savedWhileAsking = false; @@ -122,7 +127,8 @@ class _MessageComposePageState extends ConsumerState { @override void dispose() { _lifecycle.dispose(); - _controller.detach(); + // 스스로 닫지 않고 내려갔다(알림을 눌러 받은편지함으로 · 업데이트 화면으로) — 바꾼 것을 끝까지 저장한다 + _controller.detach(saveRemaining: _closedOutside || !_closing); for (final FocusNode node in _recipientFocus.values) { node.dispose(); } @@ -283,8 +289,19 @@ class _MessageComposePageState extends ConsumerState { context.go(MailRoutes.home); } - /// 화면이 닫혔다(뒤로 · 몸짓 · [_close]) — 이 화면이 저장해 둔 것이 있으면 알린다 + /// 화면이 닫혔다(뒤로 · 몸짓 · [_close] · 바깥 이동) — 이 화면이 저장해 둔 것이 있으면 알린다. + /// + /// 잃을 것이 있으면 뒤로 · 몸짓은 막혀 있다(PopScope 의 canPop) — 그런데도 스스로 닫지 않은 채 닫혔으면 바깥 이동(go — 알림 누르기 · + /// 업데이트 화면)이 이 화면을 내린 것이다. 그때는 알리지 않고, 내려가며 마저 저장한 뒤 컨트롤러가 알린다(detach) void _onClosed() { + if (!_closing) { + final ComposeState state = ref.read(composeProvider(_args)); + if (state.unsaved && !state.sending && !state.ended) { + _closedOutside = true; + _closing = true; + return; + } + } final bool announce = _closing ? _announceSaved : _controller.savedForLeave; _closing = true; if (announce) _notice(MailComposeTexts.savedOnLeave); diff --git a/flutter/lib/features/mail/presentation/providers/compose_controller.dart b/flutter/lib/features/mail/presentation/providers/compose_controller.dart index 8bb753c..724c0cc 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_controller.dart @@ -1546,6 +1546,7 @@ class ComposeController extends Notifier { } void _onSaveFailed(String code, DraftSaveTrigger trigger) { + _lastSaveFailure = code; if (!ref.mounted || _ended) return; switch (code) { case 'MAIL_004': @@ -1592,13 +1593,40 @@ class ComposeController extends Notifier { bool get savedForLeave => _savedOnce && !_sent && !state.sending && !_ended; /// 화면이 사라진다 — 더 저장하지 않는다(진행 중인 저장 · 보내기는 끝까지 받는다). 저장으로 지운 옛 저장본은 목록에서 뺀다. - /// 위젯을 정리하는 중에 불리므로 상태는 바꾸지 않는다 - void detach() { + /// 위젯을 정리하는 중에 불리므로 상태는 바꾸지 않는다. + /// + /// [saveRemaining] — 화면이 스스로 닫히지 않고 바깥 이동으로 내려갔다(알림을 눌러 받은편지함으로 · 업데이트 화면으로). + /// 바꾼 것이 남았고 보내지 않았고 세션이 그대로면 끝까지 저장한다([_saveOnDetach] — 앱 README 「쓰기」 "푸시를 붙일 때"). + /// 세션이 끝나 내려간 것은 저장하지 않는다(다른 직원의 임시보관함에 남기지 않게). + void detach({bool saveRemaining = false}) { if (!_attached) return; _attached = false; _cancelSearch(); // 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다) _uploads.close(); + if (saveRemaining && _keepsOnDetach) { + // 저장을 기다리기 전에 붙잡는다 — 화면이 사라진 뒤에도 저장이 끝날 때까지 산다(보내기와 같은 규칙) + final KeepAliveLink keepAlive = ref.keepAlive(); + final void Function(String text) notice = ref.read( + mailNoticeSinkProvider, + ); + final ErrorNotifier errors = ref.read(errorNotifierProvider); + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + // 위젯을 정리하는 중이다 — 상태는 다음 차례에 바꾼다 + scheduleMicrotask( + () => unawaited( + _saveOnDetach( + keepAlive: keepAlive, + notice: notice, + errors: errors, + watcher: watcher, + ), + ), + ); + return; + } if (!_sent && !_sendingNow) _autosave.stop(); final Set removed = {..._replacedIds}; if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) { @@ -1611,6 +1639,57 @@ class ComposeController extends Notifier { bool _sendingNow = false; + /// 마지막 저장 실패의 코드 — 바깥 이동으로 내려간 뒤의 저장이 실패하면 그 코드의 사전 문구로 알린다 + String? _lastSaveFailure; + + /// 바깥 이동으로 내려갈 때 마저 저장할까 — 보내지 않았고 · 보내는 중이 아니고 · 세션이 그대로고 · 채운 양식이다 + bool get _keepsOnDetach => + ref.mounted && + !_sent && + !_sendingNow && + _sameSession() && + state.status == ComposeStatus.ready; + + /// 바깥 이동으로 내려간 쓰기 화면의 마지막 저장 — 쓰다 만 주소까지 칩으로 만들어 저장하고, 저장했으면(또는 이 화면이 저장한 적이 있으면) + /// "임시보관함에 저장했습니다.", 못 했으면 그 코드의 사전 문구를 전역 알림으로. 세션이 바뀌면 알리지 않는다 + Future _saveOnDetach({ + required KeepAliveLink keepAlive, + required void Function(String text) notice, + required ErrorNotifier errors, + required MailStateWatcher watcher, + }) async { + try { + if (!ref.mounted || _ended) return; + // 올리던 첨부는 이미 그만두었다(detach) — 다 올린 첨부만 싣는다(보내기 · 나가기의 저장과 같다) + dropUploadingAttachments(); + commitAllRecipientInputs(); + _autosave.suspend(); + _lastSaveFailure = null; + final bool changed = hasUnsavedChanges; + final bool saved = changed ? await _autosave.saveBeforeLeave() : true; + if (!ref.mounted) return; + _autosave.stop(); + if (!_sameSession()) return; + final Set removed = {..._replacedIds}; + if (removed.isNotEmpty || _savedOnce) { + watcher.markChanged(removedIds: removed); + } + if (saved && !hasUnsavedChanges) { + if (_savedOnce) notice(MailComposeTexts.savedOnLeave); + return; + } + // 저장하지 못했다 — 찾지 못한 첨부 · 대용량 파일은 그 코드로, 그 밖은 저장이 실패한 코드로 + final String code = _hasMissingAttachments + ? 'MAIL_004' + : _hasMissingLargeFiles + ? 'MAIL_016' + : _lastSaveFailure ?? kFallbackCode; + errors.show(ApiException.fromCode(code)); + } finally { + keepAlive.close(); + } + } + // ── 보내기 ───────────────────────────────────────── /// 보낸다 — 위 머리말의 차례. [router] 는 보낸 뒤 알림의 단추(보내기 취소 · 보낸편지함 보기)가 쓴다. diff --git a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart index 0d0472c..dac20ad 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; import 'package:arasystem_app/core/utils/app_foreground.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; @@ -71,13 +72,17 @@ final NotifierProvider mailStateWatcherProvider = /// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다. /// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다. /// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다). -/// - 푸시(새 메일 알림)는 다음 차례다. +/// - 새 메일 알림 소식(앱이 앞에 있을 때 온 알림 · 누른 알림 — `core/push/push_events.dart`)이 오면 목록이 보일 때 10초 간격을 건너뛰고 +/// 바로 묻는다(묻는 중이면 끝난 뒤 한 번). 안 보이면 보일 때 위 규칙대로다. class MailStateWatcher extends Notifier { final Set _viewers = {}; Timer? _timer; bool _foreground = true; bool _signedIn = false; bool _asking = false; + + /// 묻는 중에 새 메일 알림이 왔다 — 끝난 뒤 한 번 더 묻는다 + bool _askAgain = false; DateTime? _lastAskedAt; String? _lastState; @@ -90,14 +95,22 @@ class MailStateWatcher extends Notifier { _timer?.cancel(); _timer = null; _asking = false; + _askAgain = false; _lastAskedAt = null; _lastState = null; _foreground = ref.read(appForegroundProvider); - ref.listen(appForegroundProvider, (bool? previous, bool next) { - final bool before = _active; - _foreground = next; - if (before != _active) _onActiveChanged(); - }); + ref + ..listen(appForegroundProvider, (bool? previous, bool next) { + final bool before = _active; + _foreground = next; + if (before != _active) _onActiveChanged(); + }) + ..listen(pushEventsProvider, ( + PushEvent? previous, + PushEvent? next, + ) { + if (next != null && next.message.isNewMail) _onNewMailPush(); + }); ref.onDispose(() { _timer?.cancel(); _timer = null; @@ -143,6 +156,16 @@ class MailStateWatcher extends Notifier { ); } + /// 새 메일 알림이 왔다 — 보이는 목록이 있으면 바로 묻는다(묻는 중이면 끝난 뒤 한 번) + void _onNewMailPush() { + if (!ref.mounted || !_active) return; + if (_asking) { + _askAgain = true; + return; + } + unawaited(_ask()); + } + void _onActiveChanged() { if (!_active) { _timer?.cancel(); @@ -193,7 +216,12 @@ class MailStateWatcher extends Notifier { // 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다 if (ref.mounted) { _asking = false; - _schedule(kMailStatePollInterval); + if (_askAgain) { + _askAgain = false; + unawaited(_ask()); + } else { + _schedule(kMailStatePollInterval); + } } } } diff --git a/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart b/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart new file mode 100644 index 0000000..8e25184 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 줄의 글 줄 높이 — 보드의 1.55 +const double _messageLineHeight = 1.55; + +/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(웹 5단계 목록 위 한 번 권하기와 같은 뜻). +/// +/// - 보이는 때: 안드로이드 13 이상 · 아직 묻지 않음 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 · 받을 수 있는 휴대폰 · 닫은 적 없음([showsPushOffer]). +/// 위 막대 아래에 고정이다(목록과 함께 흐르지 않는다). +/// - [알림 켜기]를 누를 때만 휴대폰이 묻는 창을 띄운다(두 번 거절하면 다시 뜨지 않는다 — 사용자가 고른 때에만 쓴다). +/// 고른 뒤 알림(③): 허용 → "새 메일 알림을 켰습니다." · 허용 안 함 → "나중에 [설정] 탭에서 …" · 이미 막힘 → 알림에 [설정 열기]. +/// 어느 쪽이든 묻고 나면 줄은 사라진다(권한이 "묻지 않음"이 아니게 된다). +/// - [✕]: 이 휴대폰에서 다시 보이지 않는다. +class PushOfferLine extends ConsumerWidget { + const PushOfferLine({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!showsPushOffer(ref.watch(pushControllerProvider))) { + return const SizedBox.shrink(); + } + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + explicitChildNodes: true, + label: PushTexts.offerRegion, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.secondaryContainer, + border: Border( + bottom: BorderSide( + color: extra.brandTintBorder, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s16, + AppSpacing.s4, + AppSpacing.s4, + AppSpacing.s4, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s12 + 1), + child: Icon( + Icons.notifications_none, + size: AppSize.iconSmall, + color: scheme.primary, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: AppSpacing.s10, + top: AppSpacing.s12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + KeepAllText( + PushTexts.offerMessage, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + height: _messageLineHeight, + ), + ), + Transform.translate( + // 단추 글자가 위 글과 같은 자리에서 시작한다(보드의 margin-left: -12) + offset: const Offset(-AppSpacing.s12, 0), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + foregroundColor: scheme.onPrimaryContainer, + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + onPressed: () => unawaited(_enable(ref)), + child: const Text(PushTexts.offerEnable), + ), + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: AppSize.iconMedium), + tooltip: PushTexts.offerDismiss, + color: scheme.onSurfaceVariant, + onPressed: () => + ref.read(pushControllerProvider.notifier).dismissOffer(), + ), + ], + ), + ), + ), + ); + } + + /// [알림 켜기] — 휴대폰이 묻는 창 → 고른 대로 알린다. 줄은 묻는 동안 사라지므로 쓸 것을 먼저 쥔다 + Future _enable(WidgetRef ref) async { + final PushController push = ref.read(pushControllerProvider.notifier); + final void Function(String text) notice = ref.read(mailNoticeSinkProvider); + final MailToastNotifier toast = ref.read(mailToastProvider.notifier); + Future openSettings() async { + if (!await push.openSettings()) notice(PushTexts.openSettingsFailed); + } + + switch (await push.turnOn()) { + case PushTurnOnResult.on: + notice(PushTexts.enabled); + case PushTurnOnResult.denied: + notice(PushTexts.deniedLater); + case PushTurnOnResult.blocked: + toast.offer( + PushTexts.blockedNotice, + actionLabel: PushTexts.openSettings, + onAction: openSettings, + ); + case PushTurnOnResult.channelOff: + toast.offer( + PushTexts.channelOffNotice, + actionLabel: PushTexts.openSettings, + onAction: openSettings, + ); + case PushTurnOnResult.failed: + case PushTurnOnResult.unavailable: + case PushTurnOnResult.busy: + // 등록 실패는 인터셉터가 사전 문구로 알렸다 — 그 밖은 알릴 것이 없다 + break; + } + } +} diff --git a/flutter/lib/features/settings/presentation/pages/settings_page.dart b/flutter/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 0000000..db6258a --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -0,0 +1,441 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/modules/module_routes.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:arasystem_app/features/settings/presentation/widgets/settings_notification_row.dart'; +import 'package:arasystem_app/shared/widgets/confirm_dialog.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄). +/// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다). +/// +/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · 앱 정보(앱 버전 · 오픈소스 라이선스) · +/// 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃]. +/// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다). +/// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다. +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({ + required this.licensesLocation, + this.sections = const [], + this.sectionLocation, + super.key, + }); + + /// 오픈소스 라이선스 화면의 경로 + final String licensesLocation; + + /// 다른 모듈이 내놓은 설정 절 + final List sections; + + /// 모듈 절의 경로 — `/settings/` + final String Function(String path)? sectionLocation; + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + } + + Future _signOut() async { + final bool confirmed = await showConfirmDialog( + context, + title: SettingsTexts.signOutTitle, + message: SettingsTexts.signOutMessage, + confirmLabel: SettingsTexts.signOut, + cancelLabel: SettingsTexts.cancel, + ); + if (!confirmed || !mounted) return; + await ref.read(sessionProvider.notifier).signOut(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final SessionState? session = ref.watch(sessionProvider).value; + final AuthSessionInfo? me = session is SignedIn ? session.info : null; + final String? version = ref.watch(appVersionProvider); + final String Function(String path)? sectionLocation = + widget.sectionLocation; + return Scaffold( + appBar: AppBar( + toolbarHeight: AppSize.appBarHeight, + titleSpacing: AppSpacing.s20, + automaticallyImplyLeading: false, + shape: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + title: Semantics(header: true, child: const Text(SettingsTexts.home)), + ), + body: ListView( + padding: const EdgeInsets.only(bottom: AppSpacing.s24), + children: [ + _Section( + title: SettingsTexts.account, + child: me == null ? const SizedBox.shrink() : _AccountRow(me: me), + ), + const _SectionDivider(), + const _Section( + title: SettingsTexts.notifications, + child: SettingsNotificationRow(), + ), + for (final ModuleSettingsSection section + in widget.sections) ...[ + const _SectionDivider(), + _LinkRow( + label: section.title, + onTap: sectionLocation == null + ? null + : () => unawaited( + context.push(sectionLocation(section.path)), + ), + ), + ], + const _SectionDivider(), + _Section( + title: SettingsTexts.about, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _VersionRow(version: version), + _LinkRow( + label: SettingsTexts.licenses, + onTap: () => + unawaited(context.push(widget.licensesLocation)), + ), + ], + ), + ), + const _SectionDivider(), + _Section( + title: SettingsTexts.support, + child: _SupportText(contact: ref.watch(supportContactProvider)), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s20, + AppSpacing.s20, + 0, + ), + child: _SignOutButton(onPressed: () => unawaited(_signOut())), + ), + ], + ), + ); + } +} + +/// 절 — 작은 회색 이름(머리) 아래에 칸 +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s18, + AppSpacing.s20, + AppSpacing.s6, + ), + child: Semantics( + header: true, + child: Text( + title, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textMuted, + ), + ), + ), + ), + child, + ], + ); + } +} + +/// 절 사이의 선 +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.s8), + child: Divider( + height: AppSize.borderWidth, + color: Theme.of(context).extension()!.borderSubtle, + ), + ); + } +} + +/// 내 정보 — 이름 첫 글자 동그라미 · 이름 · 주소 +class _AccountRow extends StatelessWidget { + const _AccountRow({required this.me}); + + final AuthSessionInfo me; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + final String name = me.name.trim().isEmpty ? me.email : me.name.trim(); + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.settingsRowMinHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s20, + vertical: AppSpacing.s6, + ), + child: Row( + spacing: AppSpacing.s14, + children: [ + ExcludeSemantics( + child: Container( + width: AppSize.settingsAvatar, + height: AppSize.settingsAvatar, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + _initialOf(name), + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + Text( + me.email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textMuted, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 동그라미의 머리글자 — 이름(없으면 주소)의 첫 글자 +String _initialOf(String name) { + final String source = name.trim(); + if (source.isEmpty) return ''; + return String.fromCharCode(source.runes.first).toUpperCase(); +} + +/// 앱 버전 — 읽지 못했으면 이름만 +class _VersionRow extends StatelessWidget { + const _VersionRow({required this.version}); + + final String? version; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? value = version; + return MergeSemantics( + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.settingsLineMinHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s20), + child: Row( + spacing: AppSpacing.s12, + children: [ + Expanded( + child: Text( + SettingsTexts.appVersion, + style: theme.textTheme.bodyLarge, + ), + ), + if (value != null) + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textMuted, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 누르면 다른 화면으로 가는 한 줄 — 이름 · 오른쪽 화살표 +class _LinkRow extends StatelessWidget { + const _LinkRow({required this.label, required this.onTap}); + + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.settingsLineMinHeight, + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s20, + 0, + AppSpacing.s12, + 0, + ), + child: Row( + spacing: AppSpacing.s12, + children: [ + Expanded(child: Text(label, style: theme.textTheme.bodyLarge)), + ExcludeSemantics( + child: Icon( + Icons.chevron_right, + size: AppSize.iconMedium, + color: extra.textMuted, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 문의 — 연락처가 있으면 둘째 줄에 진하게, 없으면 관리자에게 문의해 달라는 글 +class _SupportText extends StatelessWidget { + const _SupportText({required this.contact}); + + final String? contact; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodyMedium?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightRelaxed, + ); + final String? line = contact; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s2, + AppSpacing.s20, + AppSpacing.s8, + ), + child: line == null + ? KeepAllText(SettingsTexts.supportFallback, style: style) + : Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${keepAllWordBreak(SettingsTexts.supportLead)}\n', + ), + TextSpan( + text: keepAllWordBreak(line), + style: TextStyle( + color: extra.textStrong, + fontWeight: AppTypography.medium, + ), + ), + ], + ), + style: style, + semanticsLabel: '${SettingsTexts.supportLead}\n$line', + ), + ); + } +} + +/// [로그아웃] — 회색 테두리 단추(데이터를 지우는 일이 아니다) +class _SignOutButton extends StatelessWidget { + const _SignOutButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return OutlinedButton.icon( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(AppSize.touchMin), + foregroundColor: extra.textStrong, + side: BorderSide(color: extra.borderStrong), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + textStyle: theme.textTheme.labelLarge, + ), + icon: const Icon(Icons.logout, size: AppSize.iconSmall), + label: const Text(SettingsTexts.signOut), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart index 635fac8..a8d7c34 100644 --- a/flutter/lib/features/settings/presentation/settings_texts.dart +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -1,25 +1,33 @@ -/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다. +/// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다. +/// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`). /// -/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤). +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class SettingsTexts { - /// 설정 첫 화면 + /// 설정 화면 · 탭 static const String home = '설정'; - /// 내 정보 — 이름 · 주소 + // ── 절 이름 ── static const String account = '내 정보'; - - /// 알림 켜기 · 끄기 static const String notifications = '알림'; - - /// 앱 정보 — 판 · 오픈소스 라이선스 static const String about = '앱 정보'; + static const String support = '문의'; + + // ── 앱 정보 ── + static const String appVersion = '앱 버전'; /// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry) static const String licenses = '오픈소스 라이선스'; - /// 문의 연락처 (`GET /api/support-contact`) - static const String support = '문의 연락처'; + // ── 문의 — 연락처(`GET /api/support-contact`)가 있으면 이 글 다음 줄에 연락처 ── + static const String supportLead = '앱이나 계정 문제로 도움이 필요하면 연락해 주세요.'; - /// 로그아웃 (화면이 아니라 하는 일) + /// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴) + static const String supportFallback = '앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요.'; + + // ── 로그아웃 ── static const String signOut = '로그아웃'; + static const String signOutTitle = '로그아웃할까요?'; + static const String signOutMessage = + '로그아웃하면 이 휴대폰에서 새 메일 알림도 오지 않습니다. 다음에 쓸 때는 다시 로그인해 주세요.'; + static const String cancel = '취소'; } diff --git a/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart b/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart new file mode 100644 index 0000000..061a4f2 --- /dev/null +++ b/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 설정 › 새 메일 알림 줄의 한 모습 — 스위치 · 설명 글 · [설정 열기] +@immutable +class NotificationRowView { + const NotificationRowView({ + required this.description, + required this.checked, + required this.enabled, + this.busyLabel, + this.opensSettings = false, + }); + + /// 줄 아래 글 + final String description; + + /// 스위치가 켜진 쪽인가(켜는 중 · 끄는 중은 바꿀 값) + final bool checked; + + /// 스위치를 누를 수 있는가 + final bool enabled; + + /// 켜는 중 · 끄는 중 — 둥근 진행 표시와 화면 낭독기 글 + final String? busyLabel; + + /// [설정 열기]를 보이는가(휴대폰에서 막힘 · 새 메일 알림만 꺼짐) + final bool opensSettings; +} + +/// 줄 모습 — 앱 보드 `App1Push` ④ 의 여덟 상태(+ 읽는 중) +NotificationRowView notificationRowViewOf(PushRowStatus status) { + return switch (status) { + PushRowStatus.on => const NotificationRowView( + description: PushTexts.rowDescription, + checked: true, + enabled: true, + ), + PushRowStatus.off => const NotificationRowView( + description: PushTexts.rowDescription, + checked: false, + enabled: true, + ), + PushRowStatus.notAsked => const NotificationRowView( + description: PushTexts.rowNotAsked, + checked: false, + enabled: true, + ), + PushRowStatus.turningOn => const NotificationRowView( + description: PushTexts.rowDescription, + checked: true, + enabled: true, + busyLabel: PushTexts.turningOn, + ), + PushRowStatus.turningOff => const NotificationRowView( + description: PushTexts.rowDescription, + checked: false, + enabled: true, + busyLabel: PushTexts.turningOff, + ), + PushRowStatus.blocked => const NotificationRowView( + description: PushTexts.rowBlocked, + checked: false, + enabled: false, + opensSettings: true, + ), + PushRowStatus.channelOff => const NotificationRowView( + description: PushTexts.rowChannelOff, + checked: false, + enabled: false, + opensSettings: true, + ), + PushRowStatus.unavailable => const NotificationRowView( + description: PushTexts.rowUnavailable, + checked: false, + enabled: false, + ), + PushRowStatus.unsupported => const NotificationRowView( + description: PushTexts.rowUnsupported, + checked: false, + enabled: false, + ), + PushRowStatus.loading => const NotificationRowView( + description: PushTexts.rowDescription, + checked: false, + enabled: false, + ), + }; +} + +/// 설정 › 알림의 "새 메일 알림" 줄 — 앱 보드 `App1Push` ④ · `App1Shell`(최소 68 · 스위치 누르는 곳 64×48). +/// +/// - 스위치를 켜면 권한이 없을 때 휴대폰이 묻는 창부터 띄우고, 서버 등록이 끝나야 켜진다(실패하면 되돌아가고 사전 문구는 인터셉터가 알린다). +/// 끄면 서버 등록을 지운 뒤 꺼진다. 켜는 동안 · 끄는 동안은 바꿀 값 쪽에 두고 둥근 진행 표시를 보인다. +/// - 휴대폰에서 막혔거나 새 메일 알림만 꺼졌으면 스위치는 누를 수 없고 [설정 열기] — 휴대폰 설정에서 돌아오면 다시 읽어 모습을 바꾼다. +/// - 화면 낭독기는 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다(보드의 aria-labelledby · describedby). +class SettingsNotificationRow extends ConsumerWidget { + const SettingsNotificationRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final PushRowStatus status = pushRowStatusOf( + ref.watch(pushControllerProvider), + ); + final NotificationRowView view = notificationRowViewOf(status); + final String? busyLabel = view.busyLabel; + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.settingsRowMinHeight, + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s20, + AppSpacing.s6, + AppSpacing.s12, + AppSpacing.s6, + ), + child: Row( + spacing: AppSpacing.s12, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: view.opensSettings ? AppSpacing.s8 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + // 이름 · 설명은 스위치가 함께 읽는다(두 번 읽히지 않게 글 자체는 빼 둔다) + ExcludeSemantics( + child: Text( + PushTexts.rowLabel, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: AppTypography.medium, + color: theme.colorScheme.onSurface, + ), + ), + ), + ExcludeSemantics( + child: KeepAllText( + view.description, + style: theme.textTheme.bodySmall?.copyWith( + color: view.enabled + ? extra.textMuted + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (view.opensSettings) const _OpenSettingsButton(), + ], + ), + ), + ), + if (busyLabel != null) _BusyRing(label: busyLabel), + // 스위치 한 마디 — 이름 · 설명 · 켬/끔을 함께 읽는다 + Semantics( + container: true, + label: PushTexts.rowLabel, + hint: view.description, + child: SizedBox( + width: AppSize.switchTouchWidth, + height: AppSize.touchMin, + child: Switch( + value: view.checked, + onChanged: view.enabled + ? (bool value) => _toggle(ref, value, busy: busyLabel) + : null, + ), + ), + ), + ], + ), + ), + ); + } + + void _toggle(WidgetRef ref, bool value, {required String? busy}) { + // 켜는 중 · 끄는 중에는 받지 않는다(컨트롤러도 한 번에 하나만 한다) + if (busy != null) return; + final PushController push = ref.read(pushControllerProvider.notifier); + unawaited(value ? push.turnOn() : push.turnOff()); + } +} + +/// 켜는 중 · 끄는 중 — 둥근 진행 표시(보드: 청록 고리 · 옅은 바탕), 화면 낭독기는 글로 읽는다 +class _BusyRing extends StatelessWidget { + const _BusyRing({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + liveRegion: true, + label: label, + child: ExcludeSemantics( + child: SizedBox.square( + dimension: AppSize.progressRing, + child: CircularProgressIndicator( + strokeWidth: AppSize.progressRingStroke, + color: Theme.of(context).colorScheme.primary, + backgroundColor: AppColors.progressRingTrack, + strokeCap: StrokeCap.round, + ), + ), + ), + ); + } +} + +/// [설정 열기] — 휴대폰 설정(새 메일 알림만 꺼졌으면 그 카테고리)을 연다. 열지 못하면 알린다 +class _OpenSettingsButton extends ConsumerWidget { + const _OpenSettingsButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Transform.translate( + // 글자가 위 글과 같은 자리에서 시작한다(보드의 margin-left: -12) + offset: const Offset(-AppSpacing.s12, 0), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s12), + foregroundColor: theme.colorScheme.onPrimaryContainer, + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + onPressed: () => unawaited(_open(context, ref)), + child: const Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s6, + children: [ + Text(PushTexts.openSettings), + Icon(Icons.open_in_new, size: AppSize.iconTiny), + ], + ), + ), + ), + ); + } + + Future _open(BuildContext context, WidgetRef ref) async { + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + final bool opened = await ref + .read(pushControllerProvider.notifier) + .openSettings(); + if (opened) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar(content: KeepAllText(PushTexts.openSettingsFailed)), + ); + } +} diff --git a/flutter/lib/features/settings/settings_routes.dart b/flutter/lib/features/settings/settings_routes.dart index dc73748..7c5c9d2 100644 --- a/flutter/lib/features/settings/settings_routes.dart +++ b/flutter/lib/features/settings/settings_routes.dart @@ -1,35 +1,19 @@ -import 'dart:async'; - import 'package:arasystem_app/core/constants/app_constants.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; -import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; -import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; -import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; /// 설정 모듈의 경로 — 모두 `/settings` 아래다 abstract final class SettingsRoutes { - /// 첫 화면 — 설정 목록 (탭 안) + /// 첫 화면 — 설정 한 화면 (탭 안) static final String home = findAppModule(AppModuleId.settings).rootPath; - /// 내 정보 — 이름 · 주소 - static String get account => '$home/account'; - - /// 알림 켜기 · 끄기 - static String get notifications => '$home/notifications'; - - /// 앱 정보 — 판 · 오픈소스 라이선스 - static String get about => '$home/about'; - /// 오픈소스 라이선스 (전체 화면) - static String get licenses => '$about/licenses'; - - /// 문의 연락처 - static String get support => '$home/support'; + static String get licenses => '$home/licenses'; /// 다른 모듈이 내놓은 설정 절 (`/settings/`) static String section(String path) => '$home/$path'; @@ -37,50 +21,23 @@ abstract final class SettingsRoutes { /// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. /// -/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃. -/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다. +/// 설정은 한 화면이다(앱 보드 `App1Shell` · `App1Push` ④ — `presentation/pages/settings_page.dart`). +/// 그 아래에는 오픈소스 라이선스(전체 화면)와 다른 모듈이 내놓은 절만 있다. final ModuleRoutes settingsModuleRoutes = ModuleRoutes( id: AppModuleId.settings, buildRoutes: (ModuleRouteContext context) => [ GoRoute( path: SettingsRoutes.home, - builder: (BuildContext _, GoRouterState _) => - _SettingsHomePlaceholder(sections: context.settingsSections), + builder: (BuildContext _, GoRouterState _) => SettingsPage( + licensesLocation: SettingsRoutes.licenses, + sections: context.settingsSections, + sectionLocation: SettingsRoutes.section, + ), routes: [ GoRoute( - path: 'account', - builder: (BuildContext _, GoRouterState _) => - const PlaceholderPage(title: SettingsTexts.account), - ), - GoRoute( - path: 'notifications', - builder: (BuildContext _, GoRouterState _) => - const PlaceholderPage(title: SettingsTexts.notifications), - ), - GoRoute( - path: 'about', - builder: (BuildContext _, GoRouterState _) => PlaceholderPage( - title: SettingsTexts.about, - links: [ - PlaceholderLink( - label: SettingsTexts.licenses, - location: SettingsRoutes.licenses, - ), - ], - ), - routes: [ - GoRoute( - path: 'licenses', - parentNavigatorKey: context.rootNavigatorKey, - builder: (BuildContext _, GoRouterState _) => - const _LicensesPage(), - ), - ], - ), - GoRoute( - path: 'support', - builder: (BuildContext _, GoRouterState _) => - const PlaceholderPage(title: SettingsTexts.support), + path: 'licenses', + parentNavigatorKey: context.rootNavigatorKey, + builder: (BuildContext _, GoRouterState _) => const _LicensesPage(), ), for (final ModuleSettingsSection section in context.settingsSections) GoRoute(path: section.path, builder: section.builder), @@ -89,47 +46,6 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes( ], ); -/// 설정 첫 화면 자리 — 절 이름과 [로그아웃] -class _SettingsHomePlaceholder extends ConsumerWidget { - const _SettingsHomePlaceholder({required this.sections}); - - final List sections; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return PlaceholderPage( - title: SettingsTexts.home, - links: [ - PlaceholderLink( - label: SettingsTexts.account, - location: SettingsRoutes.account, - ), - PlaceholderLink( - label: SettingsTexts.notifications, - location: SettingsRoutes.notifications, - ), - for (final ModuleSettingsSection section in sections) - PlaceholderLink( - label: section.title, - location: SettingsRoutes.section(section.path), - ), - PlaceholderLink( - label: SettingsTexts.about, - location: SettingsRoutes.about, - ), - PlaceholderLink( - label: SettingsTexts.support, - location: SettingsRoutes.support, - ), - PlaceholderLink( - label: SettingsTexts.signOut, - onTap: () => unawaited(ref.read(sessionProvider.notifier).signOut()), - ), - ], - ); - } -} - /// 오픈소스 라이선스 — Flutter 라이선스 화면(LicenseRegistry — Pretendard OFL 포함) class _LicensesPage extends ConsumerWidget { const _LicensesPage(); diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index ff0ecf3..62bb31d 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -6,6 +6,8 @@ import 'package:arasystem_app/core/api/user_agent.dart'; import 'package:arasystem_app/core/constants/app_info_provider.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/core/push/firebase_push_messaging.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; import 'package:arasystem_app/core/storage/first_run_marker.dart'; import 'package:arasystem_app/core/storage/secure_store.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; @@ -39,8 +41,19 @@ Future main() async { firstRun: PrefsFirstRunMarker(), ); await tokenStore.prepare(); - final ({String? appVersion, String userAgent}) device = - await loadAppDeviceInfo(); + final ({ + String? appVersion, + String? packageName, + String userAgent, + int? androidSdkInt, + }) + device = await loadAppDeviceInfo(); + + // 새 메일 알림(FCM) — Firebase 를 띄우고 플러그인의 소식 줄(FID · 받은 알림 · 누른 알림)을 지금 듣는다. + // 띄우지 못하면(google-services.json 없이 빌드 · 5초 넘음) 알림 없이 돈다 — 설정 줄은 "이 휴대폰에서는 받을 수 없음" + final PushMessaging pushMessaging = await FirebasePushMessaging.start( + sdkInt: device.androidSdkInt, + ); // 지난 실행이 받아 둔 파일(메일 첨부)과 고른 파일의 복사본(쓰기 화면의 첨부)을 지운다 — 기다리지 않는다 // (저장소가 차례를 지켜 새로 받는 파일 · 새로 고른 파일은 지우지 않는다) @@ -57,8 +70,10 @@ Future main() async { tokenStoreProvider.overrideWithValue(tokenStore), userAgentProvider.overrideWithValue(device.userAgent), appVersionProvider.overrideWithValue(device.appVersion), + appPackageNameProvider.overrideWithValue(device.packageName), downloadedFileStoreProvider.overrideWithValue(downloadedFiles), pickedFileStoreProvider.overrideWithValue(pickedFiles), + pushMessagingProvider.overrideWithValue(pushMessaging), ], child: const App(), ), diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 441443b..73e1acb 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,8 @@ import FlutterMacOS import Foundation import device_info_plus +import firebase_core +import firebase_messaging import flutter_secure_storage_darwin import package_info_plus import shared_preferences_foundation @@ -14,6 +16,8 @@ import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 463cc21..612d5f3 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "93.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: e6d8a6eb9da10e65906dda455eca96ee7a6303b835cad74d667b1ff3bedd5d18 + url: "https://pub.dev" + source: hosted + version: "1.3.79" analyzer: dependency: transitive description: @@ -185,6 +193,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "21551c1205441d9d155660ea0a409530b65a8d66ce88002bab3f38d94a8638fc" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ec9832bfec1662629d6d09abd65021357634b2c10964970d3e96ab18c9458db" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: e19c73b369ae83f4580a0676597f9ac3a0cd91e1b18be69ea16299c153dd9310 + url: "https://pub.dev" + source: hosted + version: "17.0.1" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "1627f161b85bf842cba5227b02aafaad4e4ff9236a0a8490e6ec72b6fde294a5" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "593d73330500896d9dd81680ee6207c69ad80f258712beb6de2e32dc14a9cb26" + url: "https://pub.dev" + source: hosted + version: "4.3.1" flutter: dependency: "direct main" description: flutter diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b793afa..b9e631d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -24,6 +24,12 @@ dependencies: # 기기 정보(Android 판 · 기종) — User-Agent 만들기 device_info_plus: ^13.3.0 dio: ^5.9.2 + # 새 메일 알림(FCM) — Firebase 를 띄운다(core/push/firebase_push_messaging.dart). 5.1.0 은 2026-10-23 배포(FlutterFire). + # 안드로이드 설정은 android/app/google-services.json(저장소에 넣지 않는다 — flutter/README "새 메일 알림") + firebase_core: ^5.1.0 + # 새 메일 알림 — FID 등록(register · onRegistered) · 알림 권한 · 누른 알림. 17.0.1 은 2026-10-23 배포(FlutterFire). + # 토큰 API(getToken · onTokenRefresh)는 17.0 부터 deprecated 라 쓰지 않는다. 이 파일 밖에서는 import 하지 않는다 + firebase_messaging: ^17.0.1 flutter_dotenv: ^5.2.1 flutter_riverpod: ^3.3.1 flutter_secure_storage: ^10.0.0 diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 1e9af23..44aa28b 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -14,6 +14,7 @@ import 'package:arasystem_app/features/mail/mail_routes.dart'; import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart'; import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -159,7 +160,7 @@ void main() { await tester.tap(tab('설정')); await tester.pumpAndSettle(); - expect(page(SettingsTexts.home), findsOneWidget); + expect(find.byType(SettingsPage), findsOneWidget); await tester.tap(tab('메일')); await tester.pumpAndSettle(); @@ -216,7 +217,7 @@ void main() { } }); - testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', ( + testWidgets('설정은 한 화면 — 절과 [로그아웃], 확인 창에서 [로그아웃]을 고르면 로그인 화면으로', ( WidgetTester tester, ) async { final GoRouter router = await pumpSignedIn(tester); @@ -224,22 +225,47 @@ void main() { await tester.tap(tab('설정')); await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget); for (final String item in [ SettingsTexts.account, SettingsTexts.notifications, SettingsTexts.about, SettingsTexts.support, - SettingsTexts.signOut, ]) { expect(page(item), findsOneWidget, reason: item); } - await tester.tap(page(SettingsTexts.signOut)); + // [로그아웃]은 목록 끝이다 — 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다 + final Finder signOut = find.widgetWithText( + OutlinedButton, + SettingsTexts.signOut, + ); + await tester.scrollUntilVisible( + signOut, + 200, + scrollable: find.descendant( + of: find.byType(SettingsPage), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(signOut); + await tester.pumpAndSettle(); + expect(find.text(SettingsTexts.signOutTitle), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text(SettingsTexts.signOut), + ), + ); await tester.pumpAndSettle(); expect(find.byType(LoginPage), findsOneWidget); expect(router.routerDelegate.currentConfiguration.uri.path, '/login'); expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + expect(h.push.unregistered, 1); }); }); diff --git a/flutter/test/core/app_config/app_config_test.dart b/flutter/test/core/app_config/app_config_test.dart new file mode 100644 index 0000000..7f430b4 --- /dev/null +++ b/flutter/test/core/app_config/app_config_test.dart @@ -0,0 +1,372 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; +import 'package:arasystem_app/core/app_config/app_config_repository.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/provider_harness.dart'; +import '../../support/push_fakes.dart'; + +/// 앱 설정 · 최소 판 확인 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정") +void main() { + group('판 읽기 · 견주기', () { + test('숫자 셋 — 모자란 칸은 0, `-` · `+` 뒤는 버린다', () { + expect(AppVersion.tryParse('1.2.3'), const AppVersion(1, 2, 3)); + expect(AppVersion.tryParse('1.2'), const AppVersion(1, 2, 0)); + expect(AppVersion.tryParse('3'), const AppVersion(3, 0, 0)); + expect(AppVersion.tryParse(' 1.2.3 '), const AppVersion(1, 2, 3)); + expect(AppVersion.tryParse('1.2.3-beta.1'), const AppVersion(1, 2, 3)); + expect(AppVersion.tryParse('1.2.3+7'), const AppVersion(1, 2, 3)); + expect(AppVersion.tryParse('1.0.0-rc+4'), const AppVersion(1, 0, 0)); + expect(AppVersion.tryParse('1.2.3')?.toString(), '1.2.3'); + expect( + AppVersion.tryParse('9999.0.9999'), + const AppVersion(9999, 0, 9999), + reason: '칸마다 4자리까지 — 서버의 판 칸과 같다', + ); + }); + + test('읽지 못하는 판은 null — 칸 넷 · 숫자 아님 · 빈 칸 · 다섯 자리 칸', () { + for (final String? text in [ + null, + '', + 'a.b.c', + '1.2.3.4', + '1..2', + '1.-2.0', + '.1.2', + '1.2.', + '12345.0.0', + '1.2.10000', + 'v1.2.3', + '-1.0.0', + ]) { + expect(AppVersion.tryParse(text), isNull, reason: '$text'); + } + }); + + test('칸마다 숫자로 견준다 — 1.10.0 은 1.9.9 보다 높다', () { + expect(const AppVersion(1, 9, 9) < const AppVersion(1, 10, 0), isTrue); + expect(const AppVersion(1, 99, 99) < const AppVersion(2, 0, 0), isTrue); + expect(const AppVersion(1, 2, 3) < const AppVersion(1, 2, 3), isFalse); + expect( + const AppVersion(1, 2, 4).compareTo(const AppVersion(1, 2, 3)), + greaterThan(0), + ); + }); + + test('업데이트해야 하는 때는 이 앱이 최소 판보다 낮을 때뿐 — 어느 쪽이든 읽지 못하면 막지 않는다', () { + const AppVersion minimum = AppVersion(1, 0, 1); + expect( + isAppUpdateRequired(appVersion: '1.0.0', minimum: minimum), + isTrue, + ); + expect( + isAppUpdateRequired(appVersion: '1.0.1', minimum: minimum), + isFalse, + ); + expect(isAppUpdateRequired(appVersion: '1.1', minimum: minimum), isFalse); + expect(isAppUpdateRequired(appVersion: null, minimum: minimum), isFalse); + expect(isAppUpdateRequired(appVersion: 'dev', minimum: minimum), isFalse); + expect(isAppUpdateRequired(appVersion: '0.9.0', minimum: null), isFalse); + }); + + test('서버에 알리는 판은 숫자 셋 꼴로 맞춘다', () { + expect(normalizedAppVersion('1.2'), '1.2.0'); + expect(normalizedAppVersion('1.0.0-beta'), '1.0.0'); + expect(normalizedAppVersion('dev'), isNull); + expect(normalizedAppVersion(null), isNull); + }); + }); + + group('응답 읽기 (AppConfig.fromJson)', () { + test('안드로이드의 최소 판 · 알림을 쓸 수 있는지', () { + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': { + 'android': '1.2.0', + 'ios': null, + }, + 'pushAvailable': true, + }), + const AppConfig( + minSupportedVersion: AppVersion(1, 2, 0), + pushAvailable: true, + ), + ); + }); + + test('모양이 틀린 칸은 없는 것으로 본다(막지 않는다 · 알림은 모른다)', () { + expect(AppConfig.fromJson(null), const AppConfig()); + expect(AppConfig.fromJson('1.0.0'), const AppConfig()); + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': '1.0.0', + 'pushAvailable': 'yes', + }), + const AppConfig(), + ); + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': {'android': 'latest'}, + 'pushAvailable': false, + }), + const AppConfig(pushAvailable: false), + ); + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': {'ios': '2.0.0'}, + }), + const AppConfig(), + reason: '다른 플랫폼의 판은 보지 않는다', + ); + }); + }); + + group('저장소 — 로그인 없음 · 알림 끔', () { + late ApiHarness api; + + setUp(() => api = ApiHarness()); + + test('받은 값을 읽는다 — Bearer 를 붙이지 않고 알리지 않는다', () async { + await api.prepare(); + await api.signIn(); + api.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({ + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': true, + }), + ); + + final AppConfig config = await AppConfigRepository(api.dio).fetch(); + + expect(config.pushAvailable, isTrue); + final RequestOptions sent = api.server.requests.single; + expect(bearerTokenOf(sent), isNull); + expect(sent.extra[kSilentExtraKey], isTrue); + }); + + test('앱의 저장소는 인증 줄 밖으로 보낸다 — 앞 요청의 미리 갱신을 기다리지 않는다', () async { + // 앱을 막 띄웠다 — 보호 요청(me)이 보내기 전에 갱신하는데, 갱신이 아직 답하지 않는다 + final ProviderHarness h = ProviderHarness(); + await h.prepare(refreshToken: 'refresh-1'); + final Completer refreshGate = Completer(); + h.server + ..on('POST', ApiPaths.appRefresh, (_) async { + await refreshGate.future; + return FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: h.clock.now), + }); + }) + ..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())) + ..on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({'pushAvailable': true}), + ); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + final Future> me = container + .read(dioProvider) + .get(ApiPaths.me); + await pumpEventQueue(); + expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1)); + + final AppConfig config = await container + .read(appConfigRepositoryProvider) + .fetch(); + + expect(config.pushAvailable, isTrue); + expect(h.server.received('GET', ApiPaths.me), isEmpty, reason: '갱신 전'); + refreshGate.complete(); + await me; + }); + + test('앱을 막 띄웠을 때(리프레시 토큰만) 미리 갱신하지 않는다', () async { + await api.prepare(); + await api.coldStartWithRefreshToken('refresh-1'); + api.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({'pushAvailable': true}), + ); + + await AppConfigRepository(api.dio).fetch(); + + expect(api.server.received('POST', ApiPaths.appRefresh), isEmpty); + expect(api.server.requests, hasLength(1)); + }); + + test( + '그 경로가 없는 옛 서버(404 RES_001)는 받은 것으로 본다 — 최소 판 없음 · 알림 쓸 수 없음', + () async { + await api.prepare(); + api.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.fail(404, 'RES_001'), + ); + + expect( + await AppConfigRepository(api.dio).fetch(), + AppConfig.notProvided, + ); + expect(api.notifier.shownCodes, isEmpty); + }, + ); + + test('그 밖의 실패는 DioException 으로 던지고 알리지 않는다', () async { + await api.prepare(); + api.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.fail(503, 'SYS_001'), + ); + + expect( + await errorOf(AppConfigRepository(api.dio).fetch()), + isA(), + ); + expect(api.notifier.shownCodes, isEmpty); + }); + }); + + group('컨트롤러 — 띄울 때 한 번 · 앞으로 올 때 1시간 · 실패는 1분', () { + late ProviderHarness h; + late ProviderContainer container; + late int requests; + late FakeResponse Function() respond; + + setUp(() async { + h = ProviderHarness(); + requests = 0; + respond = () => FakeResponse.ok({ + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': true, + }); + h.server.on('GET', ApiPaths.appConfig, (_) { + requests++; + return respond(); + }); + await h.prepare(); + container = ProviderContainer( + overrides: [ + ...h.overrides, + appForegroundProvider.overrideWith(FakeForeground.new), + appVersionProvider.overrideWithValue('1.0.0'), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + container + ..listen(appConfigProvider, (_, _) {}) + ..listen(appUpdateRequiredProvider, (_, _) {}) + ..listen(pushAvailableProvider, (_, _) {}); + }); + + void comeBack() { + final FakeForeground foreground = + container.read(appForegroundProvider.notifier) as FakeForeground; + foreground + ..set(false) + ..set(true); + } + + Future idle() async { + for (int turn = 0; turn < 40; turn++) { + await Future.delayed(Duration.zero); + } + } + + test('만들자마자 한 번 묻고, 앞으로 와도 1시간 안이면 다시 묻지 않는다', () async { + await pumpEventsUntil( + () => container.read(appConfigProvider).fetchedAt != null, + ); + expect(requests, 1); + expect(container.read(pushAvailableProvider), isTrue); + expect(container.read(appUpdateRequiredProvider), isFalse); + + h.clock.advance(const Duration(minutes: 59)); + comeBack(); + await idle(); + expect(requests, 1); + + h.clock.advance(const Duration(minutes: 1)); + comeBack(); + await pumpEventsUntil(() => requests == 2); + }); + + test('최소 판이 이 앱보다 높으면 업데이트해야 하고, 내리면 다음 확인에서 풀린다', () async { + respond = () => FakeResponse.ok({ + 'minSupportedVersion': {'android': '1.0.1'}, + 'pushAvailable': true, + }); + await pumpEventsUntil(() => container.read(appUpdateRequiredProvider)); + + respond = () => FakeResponse.ok({ + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': true, + }); + h.clock.advance(const Duration(hours: 1)); + comeBack(); + await pumpEventsUntil(() => !container.read(appUpdateRequiredProvider)); + }); + + test('받지 못하면 막지 않고 알리지 않는다 — 앞으로 올 때 1분 간격으로 다시 묻는다', () async { + respond = () => FakeResponse.offline; + await pumpEventsUntil( + () => container.read(appConfigProvider).failedAt != null, + ); + expect(container.read(appUpdateRequiredProvider), isFalse); + expect(container.read(pushAvailableProvider), isNull); + expect(h.notifier.shownCodes, isEmpty); + + h.clock.advance(const Duration(seconds: 59)); + comeBack(); + await idle(); + expect(requests, 1); + + respond = () => + FakeResponse.ok({'pushAvailable': false}); + h.clock.advance(const Duration(seconds: 1)); + comeBack(); + await pumpEventsUntil( + () => container.read(pushAvailableProvider) == false, + ); + expect(requests, 2); + }); + + test('받은 뒤의 실패는 받은 값을 그대로 둔다', () async { + await pumpEventsUntil( + () => container.read(appConfigProvider).fetchedAt != null, + ); + respond = () => FakeResponse.fail(503, 'SYS_001'); + await container.read(appConfigProvider.notifier).refresh(); + + expect(container.read(pushAvailableProvider), isTrue); + expect(container.read(appConfigProvider).failedAt, isNotNull); + }); + + test('기기 시계를 뒤로 돌렸으면 지난 시간을 모르니 묻는다', () async { + await pumpEventsUntil(() => requests == 1); + await idle(); + h.clock.advance(const Duration(minutes: -10)); + comeBack(); + await pumpEventsUntil(() => requests == 2); + }); + }); +} diff --git a/flutter/test/core/push/push_app_test.dart b/flutter/test/core/push/push_app_test.dart new file mode 100644 index 0000000..ec09c75 --- /dev/null +++ b/flutter/test/core/push/push_app_test.dart @@ -0,0 +1,349 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/router/app_routes.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/compose_fakes.dart'; +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/push_fakes.dart'; + +/// 앱 전체에서 본 새 메일 알림 — 누른 알림의 이동 · 쓰기 화면이 내려갈 때 마저 저장 · 앞에서 받은 알림의 새 메일 확인 +void main() { + late MailAppHarness h; + late FakeComposeBackend c; + late FakePushMessaging messaging; + + Future inlineQuote(ComposeQuoteRequest request) async => + initialQuoteOf(request); + + List pushOverrides(FakePushMessaging fake) => [ + pushMessagingProvider.overrideWithValue(fake), + notificationSystemProvider.overrideWithValue(FakeNotificationSystem()), + pushPreferencesProvider.overrideWithValue(MemoryPushPreferences()), + ]; + + setUp(() { + h = MailAppHarness(); + c = FakeComposeBackend(h.server, h.mail); + messaging = FakePushMessaging()..current = PushPermission.granted; + h.overrides.addAll([ + composeQuoteBuilderProvider.overrideWithValue(inlineQuote), + ...pushOverrides(messaging), + ]); + h.server.serverTime = kTestNow; + }); + + Future start(WidgetTester tester, {String? location}) async { + c.install(); + h.mail.seed(kInboxId, 2); + await h.pump(tester, location: location); + } + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + group('누른 알림 — 늘 메일 탭 받은편지함', () { + testWidgets('다른 탭(설정)에 있다가 누르면 받은편지함으로 간다', (WidgetTester tester) async { + await start(tester); + await tester.tap(find.text(SettingsTexts.home).last); + await tester.pumpAndSettle(); + expect(h.location(), '/settings'); + + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + expect(find.byType(MailListPage), findsOneWidget); + }); + + testWidgets('읽기 화면에 있다가 누르면 읽기 화면을 닫고 받은편지함으로 간다', ( + WidgetTester tester, + ) async { + await start(tester); + unawaited(h.router.push(MailRoutes.message('m1'))); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsOneWidget); + + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsNothing); + expect(h.location(), MailRoutes.home); + }); + + testWidgets('꺼져 있다가 뜬 알림은 세션을 되살린 뒤 받은편지함 — 한 번만 가져간다', ( + WidgetTester tester, + ) async { + messaging.initial = kNewMailPush; + await start(tester); + + expect(h.location(), MailRoutes.home); + expect( + h.container.read(pushEventsProvider.notifier).takeOpened(), + isNull, + reason: '라우터가 가져갔다', + ); + }); + + testWidgets('로그아웃 상태면 누른 알림은 로그인 화면에 머문다', (WidgetTester tester) async { + final LoginHarness login = LoginHarness(); + final FakePushMessaging fake = FakePushMessaging(); + await login.pump(tester, overrides: pushOverrides(fake)); + expect(find.byType(LoginPage), findsOneWidget); + + fake.tap(kNewMailPush); + await tester.pumpAndSettle(); + + expect(find.byType(LoginPage), findsOneWidget); + }); + }); + + group('쓰기 화면이 알림으로 내려가면 임시보관함에 마저 저장한다', () { + testWidgets('바꾼 것이 있으면 쓰다 만 주소까지 저장하고 "임시보관함에 저장했습니다."', ( + WidgetTester tester, + ) async { + await start(tester); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('compose-body')), + '알림을 누르기 전에 쓰던 글', + ); + await tester.pump(); + + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + // 저장은 화면이 내려간 뒤 뒤에서 이어진다 + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(h.location(), MailRoutes.home); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], contains('알림을 누르기 전에 쓰던 글')); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('저장하지 못하면 그 코드의 사전 문구로 알린다', (WidgetTester tester) async { + await start(tester); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('compose-body')), + '저장이 실패할 글', + ); + await tester.pump(); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves, hasLength(1)); + expect(h.notifier.shownCodes, ['MAIL_001']); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('세션에 제한이 생겨(갱신 응답) 제한 단계로 옮겨 내려가면 알리지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('compose-body')), + '제한 전에 쓰던 글', + ); + await tester.pump(); + // 서버는 제한된 세션의 임시 저장을 막는다(403 AUTH_010) — me 도 제한을 말한다 + c.draftFailures + ..add(FakeResponse.fail(403, 'AUTH_010')) + ..add(FakeResponse.fail(403, 'AUTH_010')); + h.server.on( + 'GET', + ApiPaths.me, + (_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')), + ); + + h.container + .read(sessionEventsProvider) + .emit(SessionRefreshed(sessionJson(restriction: 'passwordChange'))); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(h.location(), AppRoutes.passwordChange); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.notifier.shownCodes, isNot(contains('AUTH_010'))); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('바꾼 것이 없고 저장한 적도 없으면 조용히 내려간다', (WidgetTester tester) async { + await start(tester); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves, isEmpty); + expect(find.byType(SnackBar), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('업데이트 화면 (최소 판)', () { + testWidgets('이 앱이 최소 판보다 낮아지면 어느 화면에서든 업데이트 화면 — 쓰던 메일은 저장하고, 풀리면 돌아온다', ( + WidgetTester tester, + ) async { + String minimum = '1.0.0'; + h.overrides.add(appVersionProvider.overrideWithValue('1.0.0')); + h.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({ + 'minSupportedVersion': {'android': minimum}, + 'pushAvailable': true, + }), + ); + await start(tester); + expect(h.location(), MailRoutes.home); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('compose-body')), + '업데이트 전에 쓰던 글', + ); + await tester.pump(); + + minimum = '1.1.0'; + unawaited(h.container.read(appConfigProvider.notifier).refresh()); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(h.location(), '/update'); + expect(find.text(ScreenTitles.update), findsOneWidget); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.call, 'POST'); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + + // 업데이트 화면에서는 누른 알림도 업데이트 화면으로 돌아온다 + messaging.tap(kNewMailPush); + await tester.pumpAndSettle(); + expect(h.location(), '/update'); + + minimum = '1.0.0'; + unawaited(h.container.read(appConfigProvider.notifier).refresh()); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + }); + + testWidgets('로그인 전 화면에서도 업데이트 화면이다', (WidgetTester tester) async { + final LoginHarness login = LoginHarness(); + login.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({ + 'minSupportedVersion': {'android': '2.0.0'}, + 'pushAvailable': true, + }), + ); + await login.pump( + tester, + overrides: [appVersionProvider.overrideWithValue('1.0.0')], + ); + + expect(find.text(ScreenTitles.update), findsOneWidget); + expect(find.byType(LoginPage), findsNothing); + }); + }); + + group('앞에 있을 때 온 새 메일 알림', () { + testWidgets('목록이 보이면 10초 간격을 건너뛰고 바로 새 메일을 확인한다', ( + WidgetTester tester, + ) async { + await start(tester); + final int asked = h.mail.stateRequests; + + messaging.receive(kNewMailPush); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, asked + 1); + + // 세션 끝 알림은 새 메일이 아니다 + messaging.receive(kSessionEndedPush); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, asked + 1); + // 1분 차례는 그대로 이어 간다 + h.clock.advance(kMailStatePollInterval); + await tester.pump(kMailStatePollInterval); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, asked + 2); + }); + + testWidgets('묻는 중에 오면 그 물음이 끝난 뒤 한 번 더 묻는다', (WidgetTester tester) async { + await start(tester); + final int asked = h.mail.stateRequests; + final Completer gate = Completer(); + h.mail.stateGate = gate; + h.clock.advance(kMailStatePollInterval); + await tester.pump(kMailStatePollInterval); + expect(h.mail.stateRequests, asked + 1, reason: '1분 차례의 물음이 서버에 닿았다'); + + messaging.receive(kNewMailPush); + await tester.pump(); + expect(h.mail.stateRequests, asked + 1, reason: '묻는 중에는 겹쳐 묻지 않는다'); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, asked + 2); + }); + + testWidgets('목록이 안 보이면(설정 탭) 묻지 않고, 돌아오면 지금 규칙대로', ( + WidgetTester tester, + ) async { + await start(tester); + await tester.tap(find.text(SettingsTexts.home).last); + await tester.pumpAndSettle(); + final int asked = h.mail.stateRequests; + + messaging.receive(kNewMailPush); + await tester.pumpAndSettle(); + + expect(h.mail.stateRequests, asked); + }); + }); +} diff --git a/flutter/test/core/push/push_controller_test.dart b/flutter/test/core/push/push_controller_test.dart new file mode 100644 index 0000000..13772be --- /dev/null +++ b/flutter/test/core/push/push_controller_test.dart @@ -0,0 +1,744 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/session_events.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/push_fakes.dart'; + +/// 새 메일 알림 컨트롤러 — 진짜 세션 노티파이어와 가짜 FCM · 알림창 · 서버로 흐름을 본다(계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘") +void main() { + late PushHarness h; + late ProviderContainer container; + + /// 앱을 띄운다 — [refreshToken] 이 있으면 세션을 되살린다(갱신 → me → 등록) + Future start({ + String? refreshToken = 'refresh-1', + PushPermission permission = PushPermission.granted, + }) async { + h.messaging.current = permission; + await h.prepare(refreshToken: refreshToken); + container = h.container(); + addTearDown(container.dispose); + container + ..listen(sessionProvider, (_, _) {}) + ..listen(pushControllerProvider, (_, _) {}) + ..listen(pushEventsProvider, (_, _) {}); + } + + PushState push() => container.read(pushControllerProvider); + PushController controller() => + container.read(pushControllerProvider.notifier); + + /// 이벤트 차례를 넉넉히 넘긴다 — "보내지 않았다"를 볼 때 + Future idle() async { + for (int turn = 0; turn < 60; turn++) { + await Future.delayed(Duration.zero); + } + } + + Future until(bool Function() condition, String reason) => + pumpEventsUntil(condition, reason: reason); + + Future signedIn() async { + await until( + () => container.read(sessionProvider).value is SignedIn, + '세션 되살리기', + ); + } + + /// 앱이 뒤로 갔다가 앞으로 온다 + void comeBack() { + final FakeForeground foreground = + container.read(appForegroundProvider.notifier) as FakeForeground; + foreground + ..set(false) + ..set(true); + } + + setUp(() => h = PushHarness()); + + group('등록 (PUT — FID 를 받았을 때 · 바뀌었을 때 · 세션이 열릴 때 한 번)', () { + test('세션을 되살리고 켬 · 허락이면 FCM 에 등록하고 받은 FID 로 PUT 한다', () async { + await start(); + await signedIn(); + await until(() => h.push.puts.isNotEmpty, '등록'); + + expect(h.push.puts.single.body, { + 'fid': 'fid-1', + 'platform': 'android', + 'appVersion': '1.0.0', + }); + expect(h.push.puts.single.bearer, 'access-2'); + expect(h.messaging.registerCalls, 1); + await until(() => push().registered, '등록 상태'); + expect(h.prefs.values.lastFid, 'fid-1'); + expect(h.prefs.values.serverRegistered, isTrue); + // 저절로 하는 등록은 알리지 않는다 + final RequestOptions sent = h.server + .received('PUT', ApiPaths.pushRegistration) + .single; + expect(sent.extra[kSilentExtraKey], isTrue); + }); + + test('FID 가 바뀌면 다시 PUT 하고, 같은 FID 가 다시 오면 보내지 않는다', () async { + await start(); + await until(() => h.push.puts.isNotEmpty, '첫 등록'); + + h.messaging.emitFid('fid-1'); + await idle(); + expect(h.push.puts, hasLength(1)); + + h.messaging.emitFid('fid-2'); + await until(() => h.push.puts.length == 2, 'FID 바뀜'); + expect(h.push.puts.last.body['fid'], 'fid-2'); + await until(() => h.prefs.values.lastFid == 'fid-2', '마지막 FID'); + }); + + test('지난 실행에 올린 FID 가 있으면 등록 소식을 기다리지 않고 바로 PUT 한다(앱을 띄울 때 한 번)', () async { + h.prefs.values = const PushPreferenceValues( + lastFid: 'fid-old', + serverRegistered: true, + ); + h.messaging.fidOnRegister = null; + + await start(); + await until(() => h.push.puts.isNotEmpty, '등록'); + + expect(h.push.puts.single.body['fid'], 'fid-old'); + // FCM 등록은 그대로 한 번 한다(FID 가 바뀌었으면 그 소식이 다시 PUT 한다) + expect(h.messaging.registerCalls, 1); + }); + + test('앞으로 올 때마다 PUT 하지 않는다', () async { + await start(); + await until(() => h.push.puts.isNotEmpty, '등록'); + + comeBack(); + await idle(); + comeBack(); + await idle(); + + expect(h.push.puts, hasLength(1)); + }); + + test('끈 휴대폰 · 허락 전 · 채널 꺼짐이면 FCM 에도 서버에도 등록하지 않는다', () async { + Future expectNoRegistration( + PushPermission permission, + void Function() arrange, + ) async { + h = PushHarness(); + arrange(); + await start(permission: permission); + await signedIn(); + await idle(); + expect(h.push.puts, isEmpty); + expect(h.push.deletes, isEmpty, reason: '서버 줄이 없다고 기억한다'); + expect(h.messaging.registerCalls, 0); + } + + await expectNoRegistration( + PushPermission.granted, + () => h.prefs.values = const PushPreferenceValues(enabled: false), + ); + await expectNoRegistration(PushPermission.notAsked, () {}); + await expectNoRegistration(PushPermission.denied, () {}); + await expectNoRegistration(PushPermission.granted, () { + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ); + }); + }); + + test( + '서버가 보낼 수 없다면(pushAvailable false · 그 경로가 없는 옛 서버) 로그인해도 등록하지 않는다', + () async { + Future expectUnavailable(void Function() arrange) async { + h = PushHarness(); + arrange(); + await start(refreshToken: null); + await until(() => push().server == PushServer.unavailable, '앱 설정 받기'); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({ + 'status': 'signedIn', + ...sessionJson(), + 'tokens': tokensJson(1, now: h.clock.now), + }), + ); + await container + .read(sessionProvider.notifier) + .signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'); + await idle(); + expect(h.push.puts, isEmpty); + expect(h.messaging.registerCalls, 0); + expect(pushRowStatusOf(push()), PushRowStatus.unavailable); + } + + await expectUnavailable( + () => h.push.config = { + 'minSupportedVersion': {'android': null}, + 'pushAvailable': false, + }, + ); + await expectUnavailable( + () => h.push.configResponse = FakeResponse.fail(404, 'RES_001'), + ); + }, + ); + + test('서버 줄이 있을 수 있는 휴대폰을 끈 채 띄우면 지운다(DELETE)', () async { + h.prefs.values = const PushPreferenceValues( + enabled: false, + serverRegistered: true, + ); + + await start(); + await until(() => h.push.deletes.isNotEmpty, '지우기'); + + expect(h.push.puts, isEmpty); + expect(h.push.deletes.single.extra[kSilentExtraKey], isTrue); + await until(() => !h.prefs.values.serverRegistered, '서버 줄 없음'); + }); + + test('앞으로 왔을 때 권한이 꺼졌으면 DELETE, 다시 켜졌으면 PUT', () async { + await start(); + await until(() => h.push.puts.isNotEmpty, '등록'); + await until(() => push().registered, '등록 상태'); + + h.messaging.current = PushPermission.blocked; + comeBack(); + await until(() => h.push.deletes.isNotEmpty, '권한이 꺼진 것을 알았다'); + await until(() => !push().registered, '등록 없음'); + expect(pushRowStatusOf(push()), PushRowStatus.blocked); + + h.messaging.current = PushPermission.granted; + comeBack(); + await until(() => h.push.puts.length == 2, '다시 켜짐'); + }); + + test('앞으로 왔을 때 새 메일 채널만 꺼졌으면 지운다', () async { + await start(); + await until(() => push().registered, '등록'); + + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ); + comeBack(); + await until(() => h.push.deletes.isNotEmpty, '채널이 꺼진 것을 알았다'); + expect(pushRowStatusOf(push()), PushRowStatus.channelOff); + }); + + test('PUT 이 실패해도 알리지 않고 다음에 앞으로 올 때 다시 한다', () async { + h.push.putFailure = FakeResponse.fail(503, 'MAIL_001'); + await start(); + await until(() => h.push.puts.isNotEmpty, '첫 등록'); + await idle(); + expect(push().registered, isFalse); + expect(h.notifier.shownCodes, isEmpty); + + h.push.putFailure = null; + comeBack(); + await until(() => h.push.puts.length == 2, '다시 등록'); + await until(() => push().registered, '등록 상태'); + }); + }); + + group('끝나는 때 (로그아웃 · 세션 끝 · 제한)', () { + test( + '로그아웃 — 토큰을 지운 뒤 알림창을 지우고 FCM 등록을 푼다. 알림 서버의 DELETE 는 부르지 않는다', + () async { + await start(); + await until(() => push().registered, '등록'); + bool? hadSession; + h.messaging.onUnregister = () => hadSession = h.store.hasSession; + final int clearsBefore = h.notifications.clears; + + await container.read(sessionProvider.notifier).signOut(); + await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기'); + + expect(hadSession, isFalse); + expect(h.notifications.clears, greaterThan(clearsBefore)); + expect(h.push.deletes, isEmpty); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + await until(() => !h.prefs.values.pendingUnregister, '풀기 표시 지움'); + expect(h.prefs.values.lastFid, isNull); + expect(h.prefs.values.serverRegistered, isFalse); + expect(push().registered, isFalse); + expect(push().sessionReady, isFalse); + }, + ); + + test('세션이 끝나면(AUTH_002) 알림창을 지우고 FCM 등록을 푼다', () async { + await start(); + await until(() => push().registered, '등록'); + final int clearsBefore = h.notifications.clears; + + await h.store.clear(); + container.read(sessionEventsProvider).emit(const SessionEnded()); + await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기'); + + expect(h.notifications.clears, greaterThan(clearsBefore)); + expect(h.push.deletes, isEmpty); + expect( + container.read(sessionProvider).value, + const SignedOut(reason: SignOutReason.expired), + ); + }); + + test('FCM 등록을 풀지 못하면 표시를 남기고, 로그아웃 상태로 다음에 앞으로 올 때 다시 푼다', () async { + await start(); + await until(() => push().registered, '등록'); + h.messaging.unregisterError = PlatformException(code: 'test'); + + await container.read(sessionProvider.notifier).signOut(); + await until(() => h.messaging.unregisterCalls == 1, '첫 풀기'); + await idle(); + expect(h.prefs.values.pendingUnregister, isTrue); + + h.messaging.unregisterError = null; + comeBack(); + await until(() => h.messaging.unregisterCalls == 2, '다시 풀기'); + await until(() => !h.prefs.values.pendingUnregister, '표시 지움'); + }); + + test('풀지 못한 등록이 남은 채 로그아웃 상태로 앱을 띄우면 바로 다시 푼다', () async { + h.prefs.values = const PushPreferenceValues( + pendingUnregister: true, + lastFid: 'fid-old', + ); + + await start(refreshToken: null); + await until(() => h.messaging.unregisterCalls == 1, '다시 풀기'); + await until(() => !h.prefs.values.pendingUnregister, '표시 지움'); + expect(h.messaging.registerCalls, 0); + }); + + test('같은 휴대폰에 다른 직원 — 로그아웃 뒤 새 로그인은 새 등록 소식을 기다려 새 세션으로 PUT 한다', () async { + await start(); + await until(() => push().registered, '앞 직원 등록'); + await container.read(sessionProvider.notifier).signOut(); + await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기'); + + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({ + 'status': 'signedIn', + 'email': 'doyun.lee@arasystem.it.kr', + 'name': '이도윤', + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': tokensJson(3, now: h.clock.now), + }), + ); + await container + .read(sessionProvider.notifier) + .signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw'); + await until(() => h.push.puts.length == 2, '새 직원 등록'); + + expect(h.push.puts.last.bearer, 'access-3'); + expect(h.messaging.calls, ['register', 'unregister', 'register']); + }); + + test('풀기가 실패해도 같은 실행의 다음 로그인은 FCM 에 다시 등록하고 새 세션으로 PUT 한다', () async { + await start(); + await until(() => push().registered, '앞 직원 등록'); + // 끊긴 망에서 로그아웃 — FCM 등록 풀기가 실패한다(서버 logout 도 닿지 못했을 수 있다) + h.messaging.unregisterError = PlatformException(code: 'offline'); + await container.read(sessionProvider.notifier).signOut(); + await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기(실패)'); + await idle(); + expect(h.prefs.values.pendingUnregister, isTrue); + h.messaging.unregisterError = null; + + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => FakeResponse.ok({ + 'status': 'signedIn', + 'email': 'doyun.lee@arasystem.it.kr', + 'name': '이도윤', + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': tokensJson(3, now: h.clock.now), + }), + ); + await container + .read(sessionProvider.notifier) + .signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw'); + await until(() => h.push.puts.length == 2, '새 직원 등록'); + + expect(h.messaging.calls, ['register', 'unregister', 'register']); + expect(h.push.puts.last.bearer, 'access-3'); + expect( + h.push.puts.last.body['fid'], + h.push.puts.first.body['fid'], + reason: '같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다', + ); + await until(() => !h.prefs.values.pendingUnregister, '이어진 등록이 표시를 지움'); + }); + + test('등록이 끝나기 전에 로그아웃하고 풀기가 실패하면 "풀지 못한 등록" 표시가 남는다', () async { + h.messaging.registerGate = Completer(); + await start(); + await until(() => h.messaging.registerCalls == 1, 'FCM 등록 시작'); + + // 등록이 끝나기 전에 로그아웃 — 풀기는 등록 뒤에 줄을 서고, 끊긴 망이라 실패한다 + h.messaging.unregisterError = PlatformException(code: 'offline'); + await container.read(sessionProvider.notifier).signOut(); + await idle(); + expect(h.prefs.values.pendingUnregister, isTrue); + h.messaging.registerGate!.complete(); + await until(() => h.messaging.unregisterCalls == 1, '풀기(실패)'); + await idle(); + + expect( + h.prefs.values.pendingUnregister, + isTrue, + reason: '끝난 등록이 표시를 지우지 않는다 — 다음 실행에 다시 푼다', + ); + expect(h.push.puts, isEmpty, reason: '풀리는 등록의 FID 로 올리지 않는다'); + }); + + test('세션에 제한이 생기면(갱신 응답) 알림 서버를 부르지 않고, 풀리면 다시 PUT 한다', () async { + await start(); + await until(() => push().registered, '등록'); + await until(() => h.push.seen.isNotEmpty, '앱을 띄운 뒤의 봤음'); + final SessionEventBus bus = container.read(sessionEventsProvider); + + bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange'))); + await until(() => !push().sessionReady, '멈춤'); + h.clock.advance(const Duration(minutes: 1)); + final int seenBefore = h.push.seen.length; + comeBack(); + await idle(); + expect(h.push.seen, hasLength(seenBefore)); + expect(h.push.puts, hasLength(1)); + + bus.emit(SessionRefreshed(sessionJson())); + await until(() => h.push.puts.length == 2, '제한이 풀려 다시 등록'); + }); + }); + + group('앞으로 옴 · 받은 알림 · 누른 알림', () { + test('앱을 띄우면(앞에 있음) 알림창을 지우고, 등록 뒤 봤음을 보낸다', () async { + await start(); + await until(() => push().registered, '등록'); + await until(() => h.push.seen.isNotEmpty, '봤음'); + + expect(h.notifications.clears, greaterThanOrEqualTo(1)); + expect(h.push.seen.single.extra[kSilentExtraKey], isTrue); + }); + + test('앞으로 오면 봤음을 보낸다 — 30초가 지났으면 바로', () async { + await start(); + await until(() => h.push.seen.isNotEmpty, '처음 봤음'); + + h.clock.advance(const Duration(seconds: 31)); + comeBack(); + await until(() => h.push.seen.length == 2, '앞으로 옴'); + }); + + test('앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 봤음을 보낸다. 세션 끝 알림은 알리기만', () async { + await start(); + await until(() => h.push.seen.isNotEmpty, '처음 봤음'); + h.clock.advance(const Duration(seconds: 31)); + + h.messaging.receive(kNewMailPush); + await until(() => h.push.seen.length == 2, '받은 알림 봤음'); + final PushEvent? event = container.read(pushEventsProvider); + expect(event?.opened, isFalse); + expect(event?.message.isNewMail, isTrue); + + h.clock.advance(const Duration(seconds: 31)); + h.messaging.receive(kSessionEndedPush); + await idle(); + expect(h.push.seen, hasLength(2)); + expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse); + }); + + test('누른 알림은 라우터가 가져갈 때까지 값으로 든다 — 꺼져 있다가 뜬 알림 · 뒤에서 누른 알림', () async { + h.messaging.initial = kNewMailPush; + await start(); + await until( + () => container.read(pushEventsProvider)?.opened ?? false, + '꺼져 있다가 뜬 알림', + ); + final PushEvents events = container.read(pushEventsProvider.notifier); + expect(events.takeOpened(), kNewMailPush); + expect(events.takeOpened(), isNull); + + h.messaging.tap(kNewMailPush); + await until( + () => (container.read(pushEventsProvider)?.seq ?? 0) == 2, + '뒤에서 누른 알림', + ); + expect(events.takeOpened(), kNewMailPush); + }); + }); + + group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 (시험 시계)', () { + /// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다 + Future pumpUntil( + WidgetTester tester, + bool Function() condition, + String reason, + ) async { + for (int turn = 0; turn < 300 && !condition(); turn++) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(condition(), isTrue, reason: reason); + } + + /// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다) + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + } + + /// 앱이 뒤로 간다 · 앞으로 온다(따로) + void foreground(bool value) => + (container.read(appForegroundProvider.notifier) as FakeForeground).set( + value, + ); + + testWidgets('앞에 있는 동안 30초 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + + await elapse(tester, const Duration(seconds: 10)); + comeBack(); + await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기'); + await elapse(tester, const Duration(seconds: 5)); + h.messaging.receive(kNewMailPush); + await elapse(tester, const Duration(seconds: 14)); + expect(h.push.seen, hasLength(1), reason: '30초 안이라 건너뛴다'); + + await elapse(tester, const Duration(seconds: 1)); + await pumpUntil(tester, () => h.push.seen.length == 2, '간격 끝에 한 번'); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2), reason: '건너뛴 것이 여럿이어도 끝에 한 번'); + }); + + testWidgets('간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 한 번 보내고, 끝에는 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + + await elapse(tester, const Duration(seconds: 10)); + comeBack(); + await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기'); + await elapse(tester, const Duration(seconds: 5)); + expect(h.push.seen, hasLength(1), reason: '30초 안이라 기다린다'); + + // 직원이 마지막으로 본 때는 지금이다 — 간격 끝(뒤에 있는 동안)에 보내면 그사이 온 새 메일까지 본 것으로 센다 + foreground(false); + await pumpUntil(tester, () => h.push.seen.length == 2, '뒤로 갈 때 한 번'); + await elapse(tester, const Duration(seconds: 40)); + expect(h.push.seen, hasLength(2), reason: '뒤에 있는 동안은 보내지 않는다'); + + // 다시 앞으로 오면 그때 규칙대로(마지막으로 보낸 지 30초가 지났다 — 바로) + foreground(true); + await pumpUntil(tester, () => h.push.seen.length == 3, '다시 앞으로 옴'); + }); + + testWidgets('로그아웃하면 기다리던 봤음을 보내지 않는다', (WidgetTester tester) async { + await start(); + await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음'); + comeBack(); + await tester.pump(const Duration(milliseconds: 10)); + + unawaited(container.read(sessionProvider.notifier).signOut()); + await pumpUntil(tester, () => h.messaging.unregisterCalls == 1, '로그아웃'); + await elapse(tester, const Duration(seconds: 31)); + + expect(h.push.seen, hasLength(1)); + }); + }); + + group('켜기 · 끄기 (설정 스위치 · 권하기 줄)', () { + test('켜기 — 허락 전이면 시스템 창을 띄우고, 허락하면 PUT 이 끝난 뒤 켬을 둔다(알림을 켠 요청)', () async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await start(permission: PushPermission.notAsked); + await signedIn(); + await idle(); + expect(pushRowStatusOf(push()), PushRowStatus.notAsked); + h.push.putGate = Completer(); + + final Future turning = controller().turnOn(); + await until(() => h.push.puts.isNotEmpty, 'PUT 보냄'); + expect(pushRowStatusOf(push()), PushRowStatus.turningOn); + expect(h.prefs.values.enabled, isFalse, reason: 'PUT 이 끝나야 켬을 둔다'); + h.push.putGate!.complete(); + expect(await turning, PushTurnOnResult.on); + + expect(h.messaging.permissionRequests, 1); + expect(h.push.puts.single.body['fid'], 'fid-1'); + final RequestOptions sent = h.server + .received('PUT', ApiPaths.pushRegistration) + .single; + expect(sent.extra[kSilentExtraKey], isNot(isTrue)); + expect(h.prefs.values.enabled, isTrue); + expect(pushRowStatusOf(push()), PushRowStatus.on); + }); + + test( + '켜기 — [허용 안 함]은 denied, 막혔으면 창 없이 blocked, 채널만 꺼졌으면 channelOff', + () async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await start(permission: PushPermission.notAsked); + await signedIn(); + + h.messaging.answer = PushPermission.denied; + expect(await controller().turnOn(), PushTurnOnResult.denied); + + h.messaging + ..current = PushPermission.blocked + ..answer = null; + final int asked = h.messaging.permissionRequests; + expect(await controller().turnOn(), PushTurnOnResult.blocked); + expect(h.messaging.permissionRequests, asked); + + h.messaging.current = PushPermission.granted; + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ); + expect(await controller().turnOn(), PushTurnOnResult.channelOff); + + expect(h.push.puts, isEmpty); + expect(h.prefs.values.enabled, isFalse); + }, + ); + + test('켜기 — PUT 이 실패하면 켬을 두지 않고 그 코드를 알린다', () async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await start(); + await signedIn(); + h.push.putFailure = FakeResponse.fail(503, 'MAIL_001'); + + expect(await controller().turnOn(), PushTurnOnResult.failed); + + expect(h.prefs.values.enabled, isFalse); + expect(pushRowStatusOf(push()), PushRowStatus.off); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test( + '켜기 — 처음 값(켬)인 휴대폰이 허락 전에서 켰는데 PUT 이 실패하면 끔으로 두고 저절로 다시 하지 않는다', + () async { + await start(permission: PushPermission.notAsked); + await signedIn(); + await until(() => push().loaded && push().sessionReady, '읽기'); + expect(h.prefs.values.enabled, isTrue, reason: '처음 값'); + expect(pushRowStatusOf(push()), PushRowStatus.notAsked); + h.push.putFailure = FakeResponse.fail(503, 'MAIL_001'); + + expect(await controller().turnOn(), PushTurnOnResult.failed); + + expect(h.messaging.permissionRequests, 1); + expect(h.notifier.shownCodes, ['MAIL_001']); + expect(pushRowStatusOf(push()), PushRowStatus.off); + expect(h.prefs.values.enabled, isFalse); + // 앞으로 와도 조용히 다시 올리지 않는다(직원이 다시 켤 때까지) + final int puts = h.push.puts.length; + h.push.putFailure = null; + comeBack(); + await idle(); + expect(h.push.puts, hasLength(puts)); + }, + ); + + test('켜기 — FCM 등록이 실패해 FID 를 받지 못하면 SYS_001 을 알리고 켜지 않는다', () async { + h.prefs.values = const PushPreferenceValues(enabled: false); + h.messaging.registerError = PlatformException(code: 'test'); + await start(); + await signedIn(); + + expect(await controller().turnOn(), PushTurnOnResult.failed); + + expect(h.push.puts, isEmpty); + expect(h.notifier.shownCodes, ['SYS_001']); + }); + + test('끄기 — DELETE 가 끝나야 끔을 두고, 실패하면 켬 그대로', () async { + await start(); + await until(() => push().registered, '등록'); + + h.push.deleteFailure = FakeResponse.fail(503, 'SYS_001'); + expect(await controller().turnOff(), PushTurnOffResult.failed); + expect(h.prefs.values.enabled, isTrue); + expect(pushRowStatusOf(push()), PushRowStatus.on); + expect(h.notifier.shownCodes, ['SYS_001']); + + h.push.deleteFailure = null; + expect(await controller().turnOff(), PushTurnOffResult.off); + expect(h.prefs.values.enabled, isFalse); + expect(pushRowStatusOf(push()), PushRowStatus.off); + expect(push().registered, isFalse); + expect( + h.push.deletes.last.extra[kSilentExtraKey], + isNot(isTrue), + reason: '직원이 끈 것은 실패를 알린다', + ); + }); + + test('권하기 줄 — 안드로이드 13 이상 · 묻지 않음 · 켬일 때 보이고, 닫으면 이 휴대폰에 남는다', () async { + await start(permission: PushPermission.notAsked); + await signedIn(); + await until(() => push().loaded && push().sessionReady, '읽기'); + + expect(showsPushOffer(push()), isTrue); + controller().dismissOffer(); + expect(showsPushOffer(push()), isFalse); + await until(() => h.prefs.values.offerDismissed, '닫음 기억'); + }); + + test('[설정 열기]는 휴대폰 설정을 연다', () async { + await start(); + expect(await controller().openSettings(), isTrue); + h.notifications.opens = false; + expect(await controller().openSettings(), isFalse); + expect(h.notifications.settingsOpened, 2); + }); + }); + + test('받을 수 없는 휴대폰(Firebase 를 띄우지 못함)은 아무것도 하지 않는다', () async { + h.messaging.available = false; + await start(); + await signedIn(); + await idle(); + + expect(push().deviceSupported, isFalse); + expect(pushRowStatusOf(push()), PushRowStatus.unsupported); + expect(h.messaging.calls, isEmpty); + expect(h.notifications.statusCalls, 0); + expect(h.push.puts, isEmpty); + expect(await controller().turnOn(), PushTurnOnResult.unavailable); + }); +} diff --git a/flutter/test/core/push/push_mapping_test.dart b/flutter/test/core/push/push_mapping_test.dart new file mode 100644 index 0000000..fb0ea23 --- /dev/null +++ b/flutter/test/core/push/push_mapping_test.dart @@ -0,0 +1,181 @@ +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/firebase_push_messaging.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 플랫폼 값 → 앱 값 (권한 · 알림 데이터 · 알림창 채널의 답) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('알림 권한 (firebase_messaging 17 → PushPermission)', () { + test('허락 · 임시 허락은 granted, 묻지 않음은 notAsked, 다시 묻지 못함은 blocked', () { + for (final int? sdk in [null, 30, 33, 35]) { + expect( + pushPermissionOf(AuthorizationStatus.authorized, sdkInt: sdk), + PushPermission.granted, + ); + expect( + pushPermissionOf(AuthorizationStatus.provisional, sdkInt: sdk), + PushPermission.granted, + ); + expect( + pushPermissionOf(AuthorizationStatus.notDetermined, sdkInt: sdk), + PushPermission.notAsked, + ); + expect( + pushPermissionOf(AuthorizationStatus.deniedPermanently, sdkInt: sdk), + PushPermission.blocked, + ); + } + }); + + test('거절은 안드로이드 13 이상(또는 판 모름)이면 다시 물을 수 있고, 12 이하는 휴대폰 설정에서 끈 것이다', () { + expect( + pushPermissionOf(AuthorizationStatus.denied, sdkInt: 33), + PushPermission.denied, + ); + expect( + pushPermissionOf(AuthorizationStatus.denied), + PushPermission.denied, + ); + expect( + pushPermissionOf(AuthorizationStatus.denied, sdkInt: 32), + PushPermission.blocked, + ); + expect( + pushPermissionOf(AuthorizationStatus.denied, sdkInt: 24), + PushPermission.blocked, + ); + }); + }); + + group('알림 데이터 → PushMessage (모듈 · 종류만)', () { + test('새 메일 알림 — 모듈 메일 · 종류 new-mail · 항목 없음(늘 받은편지함)', () { + final PushMessage message = pushMessageOf( + const RemoteMessage( + data: {'module': 'mail', 'kind': 'new-mail'}, + notification: RemoteNotification(title: '새 메일 3통'), + ), + ); + + expect(message.target?.module, AppModuleId.mail); + expect(message.target?.itemId, isNull); + expect(message.kind, PushKinds.newMail); + expect(message.isNewMail, isTrue); + }); + + test('세션 끝 알림은 새 메일이 아니다 · 종류가 없는 메일 알림은 새 메일로 본다 · 모르는 모듈은 갈 곳이 없다', () { + expect( + PushMessage.fromData(const { + 'module': 'mail', + 'kind': 'session-ended', + }).isNewMail, + isFalse, + ); + expect( + PushMessage.fromData(const { + 'module': 'mail', + }).isNewMail, + isTrue, + ); + final PushMessage unknown = PushMessage.fromData(const { + 'module': 'chat', + 'kind': 'new-mail', + }); + expect(unknown.target, isNull); + expect(unknown.isNewMail, isFalse); + expect( + PushMessage.fromData(const { + 'module': 'mail', + 'kind': '', + }).kind, + isNull, + ); + }); + }); + + group('알림창 채널 (Kotlin NewMailNotifications 와 짝)', () { + const MethodChannel channel = PlatformNotificationSystem.channel; + final List calls = []; + + tearDown(() { + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + void answer(Object? Function(MethodCall call) reply) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call.method); + return reply(call); + }); + } + + test('status 의 칸을 읽고, 모양이 틀린 칸은 켜짐 · 판 모름으로 본다', () async { + answer( + (_) => { + 'appEnabled': true, + 'channelEnabled': false, + 'sdkInt': 34, + }, + ); + expect( + await const PlatformNotificationSystem().status(), + const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ), + ); + + answer((_) => {'appEnabled': 'yes', 'sdkInt': '34'}); + expect( + await const PlatformNotificationSystem().status(), + const NotificationStatus(appEnabled: true, channelEnabled: true), + ); + expect(calls, ['status', 'status']); + }); + + test('채널이 실패하거나 없으면 막지 않는 쪽 — 상태는 모름(켜짐), 설정 열기는 거짓, 지우기는 조용히', () async { + answer((_) => throw PlatformException(code: 'test')); + expect( + await const PlatformNotificationSystem().status(), + NotificationStatus.unknown, + ); + expect(await const PlatformNotificationSystem().openSettings(), isFalse); + await const PlatformNotificationSystem().clearNewMail(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect( + await const PlatformNotificationSystem().status(), + NotificationStatus.unknown, + ); + expect(await const PlatformNotificationSystem().openSettings(), isFalse); + }); + + test('openSettings 는 opened 일 때만 참, clearNewMail 은 그 이름으로 부른다', () async { + answer( + (MethodCall call) => call.method == 'openSettings' ? 'opened' : null, + ); + expect(await const PlatformNotificationSystem().openSettings(), isTrue); + await const PlatformNotificationSystem().clearNewMail(); + expect(calls, ['openSettings', 'clearNewMail']); + + answer((_) => 'failed'); + expect(await const PlatformNotificationSystem().openSettings(), isFalse); + }); + }); + + test('받을 수 없는 휴대폰의 알림 받기는 비어 있다', () async { + const UnavailablePushMessaging messaging = UnavailablePushMessaging(); + expect(messaging.isAvailable, isFalse); + expect(await messaging.permission(), PushPermission.blocked); + expect(await messaging.initialOpen(), isNull); + expect(await messaging.onRegistered.isEmpty, isTrue); + }); +} diff --git a/flutter/test/core/push/push_registration_repository_test.dart b/flutter/test/core/push/push_registration_repository_test.dart new file mode 100644 index 0000000..21dc8e8 --- /dev/null +++ b/flutter/test/core/push/push_registration_repository_test.dart @@ -0,0 +1,110 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; + +/// 새 메일 알림 등록 API — 계약 1절 "앱 새 메일 알림 · 앱 설정" +void main() { + late ApiHarness api; + late PushRegistrationRepository repository; + + setUp(() async { + api = ApiHarness(); + await api.prepare(); + await api.signIn(); + repository = PushRegistrationRepository(api.dio); + }); + + test( + '등록 — PUT /push-registrations/current { fid, platform: android, appVersion } · Bearer', + () async { + api.server.on( + 'PUT', + ApiPaths.pushRegistration, + (_) => FakeResponse.ok({ + 'updatedAt': '2026-11-07T05:00:00.000Z', + }), + ); + + await repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true); + + final RequestOptions sent = api.server.requests.single; + expect(sent.method, 'PUT'); + expect(sent.path, '/push-registrations/current'); + expect(sent.data, { + 'fid': 'fid-1', + 'platform': 'android', + 'appVersion': '1.0.0', + }); + expect(bearerTokenOf(sent), 'access-1'); + expect(sent.extra[kSilentExtraKey], isTrue); + }, + ); + + test('저절로 하는 맞추기는 알리지 않고, 직원이 켜고 끈 것은 알린다', () async { + api.server + ..on( + 'PUT', + ApiPaths.pushRegistration, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ) + ..on( + 'DELETE', + ApiPaths.pushRegistration, + (_) => FakeResponse.fail(503, 'SYS_001'), + ); + + await errorOf( + repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true), + ); + await errorOf(repository.delete(silent: true)); + expect(api.notifier.shownCodes, isEmpty); + + await errorOf( + repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: false), + ); + await errorOf(repository.delete(silent: false)); + expect(api.notifier.shownCodes, ['MAIL_001', 'SYS_001']); + }); + + test('지우기 — DELETE /push-registrations/current (204)', () async { + api.server.on( + 'DELETE', + ApiPaths.pushRegistration, + (_) => const FakeResponse(204, null, rawBody: ''), + ); + + await repository.delete(silent: true); + + expect(api.server.requests.single.path, '/push-registrations/current'); + expect(api.server.requests.single.method, 'DELETE'); + }); + + test( + '봤음 — DELETE /push-registrations/current/unseen (204) · 알리지 않는다', + () async { + api.server.on( + 'DELETE', + ApiPaths.pushRegistrationUnseen, + (_) => const FakeResponse(204, null, rawBody: ''), + ); + + await repository.markSeen(); + + final RequestOptions sent = api.server.requests.single; + expect(sent.path, '/push-registrations/current/unseen'); + expect(sent.extra[kSilentExtraKey], isTrue); + + api.server.on( + 'DELETE', + ApiPaths.pushRegistrationUnseen, + (_) => FakeResponse.fail(503, 'SYS_001'), + ); + await errorOf(repository.markSeen()); + expect(api.notifier.shownCodes, isEmpty); + }, + ); +} diff --git a/flutter/test/core/push/push_state_test.dart b/flutter/test/core/push/push_state_test.dart new file mode 100644 index 0000000..b7055cc --- /dev/null +++ b/flutter/test/core/push/push_state_test.dart @@ -0,0 +1,149 @@ +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 켜짐 — 받을 수 있는 휴대폰 · 세션 · 허락 · 채널 켜짐 · 켬 (안드로이드 14) +const PushState _on = PushState( + loaded: true, + deviceSupported: true, + server: PushServer.available, + sessionReady: true, + permission: PushPermission.granted, + sdkInt: 34, +); + +void main() { + group('설정 › 새 메일 알림 줄의 모습 (보드 App1Push ④ — 앱 디자인 README 의 표)', () { + test('표 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔', () { + final List<(PushState, PushRowStatus)> table = + <(PushState, PushRowStatus)>[ + (_on, PushRowStatus.on), + (_on.copyWith(enabled: false), PushRowStatus.off), + ( + _on.copyWith(permission: PushPermission.notAsked), + PushRowStatus.notAsked, + ), + ( + _on.copyWith(permission: PushPermission.denied), + PushRowStatus.notAsked, + ), + ( + _on.copyWith(permission: PushPermission.notAsked, enabled: false), + PushRowStatus.notAsked, + ), + ( + _on.copyWith(permission: PushPermission.blocked), + PushRowStatus.blocked, + ), + ( + _on.copyWith(permission: PushPermission.blocked, enabled: false), + PushRowStatus.blocked, + ), + (_on.copyWith(channelEnabled: false), PushRowStatus.channelOff), + ( + _on.copyWith(channelEnabled: false, enabled: false), + PushRowStatus.channelOff, + ), + ( + _on.copyWith(server: PushServer.unavailable), + PushRowStatus.unavailable, + ), + (_on.copyWith(server: PushServer.unknown), PushRowStatus.on), + (const PushState(loaded: true), PushRowStatus.unsupported), + ( + _on.copyWith(change: PushChange.turningOn, enabled: false), + PushRowStatus.turningOn, + ), + ( + _on.copyWith(change: PushChange.turningOff), + PushRowStatus.turningOff, + ), + ( + const PushState(deviceSupported: true, sessionReady: true), + PushRowStatus.loading, + ), + ( + const PushState(loaded: true, deviceSupported: true), + PushRowStatus.loading, + ), + ]; + for (final (PushState state, PushRowStatus expected) in table) { + expect(pushRowStatusOf(state), expected, reason: '$state'); + } + }); + + test('켜는 중 표시를 지우면(clearChange) 원래 모습으로 돌아온다', () { + final PushState busy = _on.copyWith(change: PushChange.turningOn); + expect( + pushRowStatusOf(busy.copyWith(clearChange: true)), + PushRowStatus.on, + ); + }); + }); + + group('받은편지함 위 권하기 줄', () { + final PushState asking = _on.copyWith(permission: PushPermission.notAsked); + + test('안드로이드 13 이상 · 묻지 않음 · 켬 · 서버 · 휴대폰 · 세션 · 닫지 않음이면 보인다', () { + expect(showsPushOffer(asking), isTrue); + expect(showsPushOffer(asking.copyWith(sdkInt: 33)), isTrue); + }); + + test('하나라도 아니면 보이지 않는다', () { + expect(showsPushOffer(asking.copyWith(sdkInt: 32)), isFalse); + expect(showsPushOffer(const PushState(loaded: true)), isFalse); + expect(showsPushOffer(_on), isFalse, reason: '이미 허락'); + expect( + showsPushOffer(asking.copyWith(permission: PushPermission.denied)), + isFalse, + ); + expect(showsPushOffer(asking.copyWith(enabled: false)), isFalse); + expect(showsPushOffer(asking.copyWith(offerDismissed: true)), isFalse); + expect( + showsPushOffer(asking.copyWith(server: PushServer.unavailable)), + isFalse, + ); + expect(showsPushOffer(asking.copyWith(sessionReady: false)), isFalse); + expect( + showsPushOffer(asking.copyWith(change: PushChange.turningOn)), + isFalse, + ); + expect( + showsPushOffer( + const PushState( + deviceSupported: true, + sessionReady: true, + permission: PushPermission.notAsked, + sdkInt: 34, + ), + ), + isFalse, + reason: '값을 읽기 전', + ); + }); + }); + + group('보내야 하나 · 맞출 수 있나', () { + test('켬 ∧ 허락 ∧ 새 메일 채널일 때만 받고 싶다', () { + expect(pushWanted(_on), isTrue); + expect(pushWanted(_on.copyWith(enabled: false)), isFalse); + expect( + pushWanted(_on.copyWith(permission: PushPermission.notAsked)), + isFalse, + ); + expect(pushWanted(_on.copyWith(channelEnabled: false)), isFalse); + }); + + test('읽었고 받을 수 있는 휴대폰 · 서버(모르면 된다) · 세션일 때만 맞춘다', () { + expect(pushReachable(_on), isTrue); + expect(pushReachable(_on.copyWith(server: PushServer.unknown)), isTrue); + expect( + pushReachable(_on.copyWith(server: PushServer.unavailable)), + isFalse, + ); + expect(pushReachable(_on.copyWith(sessionReady: false)), isFalse); + expect(pushReachable(_on.copyWith(loaded: false)), isFalse); + expect(pushReachable(const PushState(loaded: true)), isFalse); + }); + }); +} diff --git a/flutter/test/core/router/session_redirect_test.dart b/flutter/test/core/router/session_redirect_test.dart index 3de0095..5a312af 100644 --- a/flutter/test/core/router/session_redirect_test.dart +++ b/flutter/test/core/router/session_redirect_test.dart @@ -114,7 +114,7 @@ void main() { expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation); expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation); expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation); - expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull); + expect(resolveSessionRedirect(_signedIn(), '/settings/licenses'), isNull); }); test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () { @@ -152,4 +152,70 @@ void main() { expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown); }); }); + + group('앱을 업데이트해야 할 때 (최소 판)', () { + test('세션과 상관없이 어느 화면에서든 업데이트 화면 — 로그인 전 화면 · 되살리는 중 · 제한 단계도', () { + final List<(AsyncValue, String)> cases = + <(AsyncValue, String)>[ + (const AsyncLoading(), AppRoutes.start), + (const AsyncData(SignedOut()), AppRoutes.login), + ( + const AsyncData( + CodeRequired('ara.kim@arasystem.it.kr'), + ), + AppRoutes.loginCode, + ), + ( + _signedIn(SessionRestriction.passwordChange), + AppRoutes.passwordChange, + ), + (_signedIn(), homeLocation), + (_signedIn(), '/mail/compose'), + (_signedIn(), '/settings/licenses'), + ]; + for (final (AsyncValue session, String location) in cases) { + expect( + resolveSessionRedirect(session, location, updateRequired: true), + AppRoutes.update, + reason: location, + ); + expect( + resolveSessionRedirect( + session, + AppRoutes.update, + updateRequired: true, + ), + isNull, + ); + } + }); + + test('풀리면 업데이트 화면은 세션이 고른 화면으로 돌아간다', () { + expect( + resolveSessionRedirect(_signedIn(), AppRoutes.update), + homeLocation, + ); + expect( + resolveSessionRedirect( + const AsyncData(SignedOut()), + AppRoutes.update, + ), + AppRoutes.login, + ); + expect( + resolveSessionRedirect( + const AsyncLoading(), + AppRoutes.update, + ), + AppRoutes.start, + ); + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.twoFactorSetup), + AppRoutes.update, + ), + AppRoutes.twoFactorSetup, + ); + }); + }); } diff --git a/flutter/test/features/app_update/app_update_page_test.dart b/flutter/test/features/app_update/app_update_page_test.dart new file mode 100644 index 0000000..1d3b18e --- /dev/null +++ b/flutter/test/features/app_update/app_update_page_test.dart @@ -0,0 +1,139 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart'; +import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart'; +import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; + +/// 시험 앱의 패키지 이름 +const String _package = 'kr.it.arasystem.app'; + +/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다 +void main() { + late LoginHarness h; + + /// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다) + final List<({Uri url, LaunchMode mode})> launched = + <({Uri url, LaunchMode mode})>[]; + late bool Function(Uri url) opens; + + Future launch(Uri url, LaunchMode mode) async { + launched.add((url: url, mode: mode)); + return opens(url); + } + + setUp(() { + h = LoginHarness(); + launched.clear(); + opens = (Uri _) => true; + h.server.on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({ + 'minSupportedVersion': {'android': '2.0.0'}, + 'pushAvailable': true, + }), + ); + }); + + Future pump(WidgetTester tester, {String? packageName = _package}) => + h.pump( + tester, + overrides: [ + appVersionProvider.overrideWithValue('1.0.0'), + appPackageNameProvider.overrideWithValue(packageName), + appStoreLauncherProvider.overrideWithValue( + AppStoreLauncher(launch: launch), + ), + ], + ); + + Finder storeButton() => + find.widgetWithText(FilledButton, AppUpdateTexts.openStore); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); + + testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', ( + WidgetTester tester, + ) async { + h.supportContact('정보보안팀 02-1234-5678'); + await pump(tester); + + expect(find.byType(AppUpdatePage), findsOneWidget); + expect(find.text(ScreenTitles.update), findsOneWidget); + expect(findCopy(AppUpdateTexts.message), findsOneWidget); + expect(storeButton(), findsOneWidget); + expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget); + expect( + findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async { + h.supportContact(null); + await pump(tester); + + expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget); + }); + + testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', ( + WidgetTester tester, + ) async { + opens = (Uri url) => url.scheme != 'market'; + await pump(tester); + + await tester.tap(storeButton()); + await tester.pumpAndSettle(); + + expect(launched, hasLength(2)); + expect(launched.first.url.toString(), 'market://details?id=$_package'); + expect(launched.first.mode, LaunchMode.externalNonBrowserApplication); + expect( + launched.last.url.toString(), + 'https://play.google.com/store/apps/details?id=$_package', + ); + expect(launched.last.mode, LaunchMode.externalApplication); + expect(snack(AppUpdateTexts.openStoreFailed), findsNothing); + }); + + testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async { + await pump(tester); + + await tester.tap(storeButton()); + await tester.pumpAndSettle(); + + expect(launched, hasLength(1)); + expect(launched.single.url.scheme, 'market'); + }); + + testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async { + opens = (Uri _) => false; + await pump(tester); + + await tester.tap(storeButton()); + await tester.pumpAndSettle(); + + expect(launched, hasLength(2)); + expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget); + }); + + testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async { + await pump(tester, packageName: null); + + await tester.tap(storeButton()); + await tester.pumpAndSettle(); + + expect(launched, isEmpty); + expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget); + }); +} diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart index 58eaddc..efdeb81 100644 --- a/flutter/test/features/auth/login_pages_test.dart +++ b/flutter/test/features/auth/login_pages_test.dart @@ -8,10 +8,17 @@ import 'package:arasystem_app/core/api/error_lexicon.dart'; import 'package:arasystem_app/core/api/error_notifier.dart'; import 'package:arasystem_app/core/api/session_events.dart'; import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; +import 'package:arasystem_app/core/app_config/app_config_repository.dart'; import 'package:arasystem_app/core/constants/login_texts.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; import 'package:arasystem_app/core/storage/token_store.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; @@ -1850,6 +1857,15 @@ bool _allowedProviderValue(Object? value) => value is MailChange || value is MailToast || value is Map || + // 앱 설정(최소 판 · 알림을 쓸 수 있는지)과 새 메일 알림 — FID · 토큰은 상태에 두지 않는다 + value is AppConfigState || + value is AppConfigRepository || + value is PushState || + value is PushEvent || + value is PushMessaging || + value is NotificationSystem || + value is PushPreferences || + value is PushRegistrationRepository || value is bool; /// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 4e7f5f8..4c9295f 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -493,6 +493,78 @@ void main() { }); }); + group('새 메일 알림 (기기 등록)', () { + Future signedIn() async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + expect(await settled(), isA()); + await pumpEventsUntil(() => h.push.registered == 1, reason: '등록'); + } + + test('세션이 끝나면(AUTH_002) 토큰을 지운 뒤 이 기기의 알림을 멈춘다', () async { + await signedIn(); + h.clock.advance(const Duration(hours: 2)); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok([])); + + await errorOf(container.read(dioProvider).get('/mailboxes')); + await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤'); + + expect(current(), const SignedOut(reason: SignOutReason.expired)); + expect(h.push.hadSessionAtUnregister, isFalse); + }); + + test('앱이 꺼진 사이 세션이 끝났으면(되살리기의 갱신 거절) 이 기기의 알림도 멈춘다', () async { + await start(refreshToken: 'refresh-1'); + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.fail(401, 'AUTH_001'), + ); + + expect(await settled(), const SignedOut()); + await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤'); + expect(h.push.registered, 0); + }); + + test('쓰던 세션에 제한이 생기면 멈추고(pause), 풀리면 다시 등록한다', () async { + await signedIn(); + final SessionEventBus bus = container.read(sessionEventsProvider); + + bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange'))); + await pumpEventsUntil( + () => restrictionNow() == SessionRestriction.passwordChange, + reason: '제한 반영', + ); + expect(h.push.paused, 1); + expect(h.push.unregistered, 0); + + bus.emit(SessionRefreshed(sessionJson())); + await pumpEventsUntil(() => h.push.registered == 2, reason: '다시 등록'); + expect(h.push.paused, 1); + }); + + test('제한된 채 로그인하면 멈출 것도 등록할 것도 없다', () async { + await start(); + await settled(); + h.server.on( + 'POST', + ApiPaths.appLogin, + (_) => signedInWith(1, restriction: 'twoFactorSetup'), + ); + + await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'); + + expect(h.push.registered, 0); + expect(h.push.paused, 0); + }); + }); + group('제한 다시 맞추기 — 노티파이어의 갈래', () { Future signedIn() async { await start(refreshToken: 'refresh-1'); diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index fbbc95d..a3c7aa5 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -1098,8 +1098,21 @@ void main() { h.router.go(SettingsRoutes.home); await tester.pumpAndSettle(); - await tester.tap(find.text('로그아웃')); + final Finder signOut = find.widgetWithText(OutlinedButton, '로그아웃'); + await tester.ensureVisible(signOut); await tester.pumpAndSettle(); + await tester.tap(signOut); + await tester.pumpAndSettle(); + // 확인 창의 [로그아웃] + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('로그아웃'), + ), + ); + await tester.pumpAndSettle(); + // 확인 창이 닫히는 동안 로그아웃한다 — 마지막 그림에서 떼어진 프로바이더는 그다음 차례에 정리된다 + await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(MailListPage), findsNothing); expect(h.container.exists(mailToastProvider), isFalse); diff --git a/flutter/test/features/mail/push_offer_line_test.dart b/flutter/test/features/mail/push_offer_line_test.dart new file mode 100644 index 0000000..aafe734 --- /dev/null +++ b/flutter/test/features/mail/push_offer_line_test.dart @@ -0,0 +1,184 @@ +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/push_screen_harness.dart'; + +/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(줄) · ③(고른 뒤 알림) +void main() { + late PushScreenHarness h; + + setUp(() { + h = PushScreenHarness(); + // 처음 모습: 안드로이드 14 · 아직 묻지 않음 — 줄이 보인다 + h.messaging.current = PushPermission.notAsked; + }); + + Finder line() => findCopy(PushTexts.offerMessage); + + Finder enableButton() => + find.widgetWithText(TextButton, PushTexts.offerEnable); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + testWidgets('보이는 때 — 받은편지함 위 막대 아래, 글 · [알림 켜기] · [닫기]', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + + expect(line(), findsOneWidget); + expect(enableButton(), findsOneWidget); + expect(find.byTooltip(PushTexts.offerDismiss), findsOneWidget); + expect(find.bySemanticsLabel(PushTexts.offerRegion), findsOneWidget); + // 목록 위에 고정이다(목록과 함께 흐르지 않는다) + expect( + tester.getBottomLeft(find.byType(PushOfferLine)).dy, + lessThanOrEqualTo(tester.getTopLeft(find.text('제목 m1')).dy), + ); + expect(h.messaging.permissionRequests, 0, reason: '누르기 전에는 묻지 않는다'); + semantics.dispose(); + }); + + group('보이지 않는 때', () { + testWidgets('다른 메일함', (WidgetTester tester) async { + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + expect(find.byType(PushOfferLine), findsNothing, reason: '받은편지함에만 둔다'); + }); + + testWidgets('안드로이드 12 이하(묻지 않고 처음부터 허용)', (WidgetTester tester) async { + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 32, + ); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('이미 허용', (WidgetTester tester) async { + h.messaging.current = PushPermission.granted; + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('닫은 적 있음', (WidgetTester tester) async { + h.prefs.values = const PushPreferenceValues(offerDismissed: true); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('이 휴대폰에서 끔', (WidgetTester tester) async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('서버가 보낼 수 없음', (WidgetTester tester) async { + h.push.config = { + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': false, + }; + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('메일을 고르는 동안', (WidgetTester tester) async { + await h.pump(tester); + expect(line(), findsOneWidget); + + await tester.longPress(find.text('제목 m1')); + await settle(tester); + + expect(line(), findsNothing); + }); + }); + + testWidgets('[알림 켜기] → [허용] — "새 메일 알림을 켰습니다." · 서버에 등록하고 줄은 사라진다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + expect(h.push.puts, isEmpty); + + await tester.tap(enableButton()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(snack(PushTexts.enabled), findsOneWidget); + expect(line(), findsNothing); + expect(h.push.puts, hasLength(1)); + expect(h.prefs.values.enabled, isTrue); + }); + + testWidgets('[알림 켜기] → [허용 안 함] — 나중에 켜는 곳을 알리고 줄은 사라진다', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.denied; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + + expect(snack(PushTexts.deniedLater), findsOneWidget); + expect(line(), findsNothing); + expect(h.push.puts, isEmpty); + }); + + testWidgets('[알림 켜기] — 휴대폰이 묻지 않고 막혀 있으면 알림 줄에 [설정 열기]', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.blocked; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + + expect(find.text(PushTexts.blockedNotice), findsOneWidget); + expect(line(), findsNothing); + await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings)); + await settle(tester); + expect(h.notifications.settingsOpened, 1); + }); + + testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.blocked; + h.notifications.opens = false; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings)); + await settle(tester); + + expect(snack(PushTexts.openSettingsFailed), findsOneWidget); + }); + + testWidgets('[닫기] — 줄이 사라지고 이 휴대폰에서 다시 보이지 않는다(묻지도 않는다)', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + await tester.tap(find.byTooltip(PushTexts.offerDismiss)); + await settle(tester); + + expect(line(), findsNothing); + expect(h.prefs.values.offerDismissed, isTrue); + expect(h.messaging.permissionRequests, 0); + }); +} diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart new file mode 100644 index 0000000..1432494 --- /dev/null +++ b/flutter/test/features/settings/settings_page_test.dart @@ -0,0 +1,460 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/push_screen_harness.dart'; + +/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄) +void main() { + late PushScreenHarness h; + + setUp(() => h = PushScreenHarness()); + + /// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답한다(연락처 없음) + void supportContact(String? text) { + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': text}), + ); + } + + Finder dialog() => find.byType(AlertDialog); + + Finder inDialog(String text) => + find.descendant(of: dialog(), matching: find.text(text)); + + Finder signOutButton() => + find.widgetWithText(OutlinedButton, SettingsTexts.signOut); + + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await settle(tester); + } + + group('설정 한 화면', () { + testWidgets('절 — 내 정보(이름 · 주소) · 알림 · 앱 정보(판) · 문의(연락처) · [로그아웃]', ( + WidgetTester tester, + ) async { + supportContact('정보보안팀 02-1234-5678'); + await h.pump(tester); + + await h.openSettings(tester); + + expect(h.app.location(), '/settings'); + for (final String title in [ + SettingsTexts.account, + SettingsTexts.notifications, + SettingsTexts.about, + SettingsTexts.support, + ]) { + expect(find.text(title), findsOneWidget, reason: title); + } + expect(find.text('김아라'), findsOneWidget); + expect(find.text(kTestEmail), findsOneWidget); + expect(find.text(PushTexts.rowLabel), findsOneWidget); + expect(find.text(SettingsTexts.appVersion), findsOneWidget); + expect(find.text(kPushScreenAppVersion), findsOneWidget); + expect(find.text(SettingsTexts.licenses), findsOneWidget); + expect( + findCopy('${SettingsTexts.supportLead}\n정보보안팀 02-1234-5678'), + findsOneWidget, + ); + expect(signOutButton(), findsOneWidget); + }); + + testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async { + supportContact(null); + await h.pump(tester); + + await h.openSettings(tester); + + expect(findCopy(SettingsTexts.supportFallback), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('[오픈소스 라이선스]는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async { + await h.pump(tester); + await h.openSettings(tester); + + await tester.ensureVisible(find.text(SettingsTexts.licenses)); + await tester.pumpAndSettle(); + await tester.tap(find.text(SettingsTexts.licenses)); + // 라이선스 화면은 읽는 동안 둥근 표시가 돈다 — 화면 넘김만 기다린다 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(LicensePage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + }); + }); + + group('[로그아웃] — 확인 창부터', () { + testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + await h.openSettings(tester); + + await tapVisible(tester, signOutButton()); + + expect(inDialog(SettingsTexts.signOutTitle), findsOneWidget); + expect( + find.descendant( + of: dialog(), + matching: findCopy(SettingsTexts.signOutMessage), + ), + findsOneWidget, + ); + final TextButton cancel = tester.widget( + find.widgetWithText(TextButton, SettingsTexts.cancel), + ); + expect(cancel.autofocus, isTrue); + + await tester.tap(inDialog(SettingsTexts.cancel)); + await settle(tester); + + expect(dialog(), findsNothing); + expect(h.app.location(), '/settings'); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + expect(h.messaging.unregisterCalls, 0); + }); + + testWidgets('[로그아웃]을 고르면 로그인 화면 — 이 휴대폰의 알림 등록도 푼다(서버 줄은 서버가 지운다)', ( + WidgetTester tester, + ) async { + await h.pump(tester); + expect(h.push.puts, hasLength(1)); + await h.openSettings(tester); + + await tapVisible(tester, signOutButton()); + await tester.tap(inDialog(SettingsTexts.signOut)); + await settle(tester); + + expect(find.byType(LoginPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + expect(h.messaging.unregisterCalls, 1); + expect(h.push.deletes, isEmpty, reason: '로그아웃은 DELETE 를 부르지 않는다'); + expect(h.notifications.clears, greaterThan(0)); + }); + }); + + group('설정 › 새 메일 알림 줄', () { + Finder switchFinder() => find.byType(Switch); + + Switch switchOf(WidgetTester tester) => + tester.widget(switchFinder()); + + Finder openSettingsButton() => + find.widgetWithText(TextButton, PushTexts.openSettings); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); + + void lifecycle(WidgetTester tester, List states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + /// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다) + Future visitPhoneSettings(WidgetTester tester) async { + lifecycle(tester, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]); + await tester.pump(); + lifecycle(tester, const [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); + await settle(tester); + } + + testWidgets('켜짐 — 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(switchOf(tester).onChanged, isNotNull); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(openSettingsButton(), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester.getSemantics(switchFinder()), + isSemantics( + label: PushTexts.rowLabel, + hint: PushTexts.rowDescription, + hasToggledState: true, + isToggled: true, + hasEnabledState: true, + isEnabled: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('끄기 — 끄는 중(둥근 표시)에는 다시 받지 않고, 서버 등록을 지운 뒤 꺼진다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + await h.openSettings(tester); + h.push.deleteGate = Completer(); + + await tester.tap(switchFinder()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(h.push.deletes, hasLength(1)); + expect(switchOf(tester).value, isFalse, reason: '바꿀 값 쪽에 둔다'); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.bySemanticsLabel(PushTexts.turningOff), findsOneWidget); + + // 끄는 중에 다시 눌러도 겹쳐 보내지 않는다 + await tester.tap(switchFinder()); + await tester.pump(const Duration(milliseconds: 100)); + expect(h.push.deletes, hasLength(1)); + + h.push.deleteGate!.complete(); + await settle(tester); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(switchOf(tester).value, isFalse); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, isEmpty); + semantics.dispose(); + }); + + testWidgets('켜기(이미 허용) — 켜는 중을 보이고, 서버에 등록한 뒤 켜진다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + await h.openSettings(tester); + expect(switchOf(tester).value, isFalse); + expect(h.push.puts, isEmpty, reason: '끈 휴대폰은 등록하지 않는다'); + h.push.putGate = Completer(); + + await tester.tap(switchFinder()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(h.push.puts, hasLength(1)); + expect(switchOf(tester).value, isTrue, reason: '바꿀 값 쪽에 둔다'); + expect(find.bySemanticsLabel(PushTexts.turningOn), findsOneWidget); + + h.push.putGate!.complete(); + await settle(tester); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(switchOf(tester).value, isTrue); + expect(h.prefs.values.enabled, isTrue); + expect(h.push.puts.single.body['fid'], 'fid-1'); + expect(h.messaging.permissionRequests, 0, reason: '이미 허용이면 묻지 않는다'); + semantics.dispose(); + }); + + testWidgets('켜기가 서버에서 실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구를 알린다', ( + WidgetTester tester, + ) async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + await h.openSettings(tester); + h.push.putFailure = FakeResponse.fail(500, 'SYS_001'); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(switchOf(tester).value, isFalse); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, ['SYS_001']); + }); + + testWidgets('허락 전(처음 값 켬)에서 켜고 [허용]했는데 서버에서 실패하면 꺼진 채로 돌아간다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.notAsked; + await h.pump(tester); + await h.openSettings(tester); + expect(h.prefs.values.enabled, isTrue, reason: '처음 값'); + h.push.putFailure = FakeResponse.fail(503, 'MAIL_001'); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNotNull); + expect( + findCopy(PushTexts.rowDescription), + findsOneWidget, + reason: '허락은 받았다', + ); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + testWidgets('허락 전 — 켜면 휴대폰이 묻고, [허용]이면 켜진다', (WidgetTester tester) async { + h.messaging.current = PushPermission.notAsked; + await h.pump(tester); + await h.openSettings(tester); + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNotNull); + expect(findCopy(PushTexts.rowNotAsked), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isTrue); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('허락 전 — [허용 안 함]이면 꺼진 채 허락 전 글 그대로, 다시 켜면 또 묻는다', ( + WidgetTester tester, + ) async { + h.messaging + ..current = PushPermission.notAsked + ..answer = PushPermission.denied; + await h.pump(tester); + await h.openSettings(tester); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isFalse); + expect(findCopy(PushTexts.rowNotAsked), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(switchFinder()); + await settle(tester); + expect(h.messaging.permissionRequests, 2); + }); + + testWidgets('휴대폰에서 막힘 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 허용됐으면 켜진다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.blocked; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowBlocked), findsOneWidget); + expect(openSettingsButton(), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(openSettingsButton()); + await settle(tester); + expect(h.notifications.settingsOpened, 1); + expect(snack(PushTexts.openSettingsFailed), findsNothing); + + h.messaging.current = PushPermission.granted; + await visitPhoneSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(openSettingsButton(), findsNothing); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.blocked; + h.notifications.opens = false; + await h.pump(tester); + await h.openSettings(tester); + + await tester.tap(openSettingsButton()); + await settle(tester); + + expect(snack(PushTexts.openSettingsFailed), findsOneWidget); + }); + + testWidgets('새 메일 알림만 꺼짐 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 켜졌으면 켜진다', ( + WidgetTester tester, + ) async { + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ); + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowChannelOff), findsOneWidget); + expect(openSettingsButton(), findsOneWidget); + expect(h.push.puts, isEmpty); + + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 34, + ); + await visitPhoneSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('서버가 보낼 수 없으면 — 스위치는 누를 수 없고 쓸 수 없다는 글, 등록하지 않는다', ( + WidgetTester tester, + ) async { + h.push.config = { + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': false, + }; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowUnavailable), findsOneWidget); + expect(openSettingsButton(), findsNothing); + // 앱 설정은 세션 되살리기(미리 갱신)를 기다리지 않고 먼저 온다 — 등록하지 않는다. 앞으로 와도 그대로 + expect(h.push.puts, isEmpty); + await visitPhoneSettings(tester); + expect(h.push.puts, isEmpty); + expect(h.push.deletes, isEmpty); + }); + + testWidgets('받을 수 없는 휴대폰이면 — 스위치는 누를 수 없고 받을 수 없다는 글', ( + WidgetTester tester, + ) async { + h.messaging.available = false; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowUnsupported), findsOneWidget); + expect(h.messaging.registerCalls, 0); + expect(h.push.puts, isEmpty); + }); + }); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart index 1e171eb..91b15cb 100644 --- a/flutter/test/support/fakes.dart +++ b/flutter/test/support/fakes.dart @@ -109,6 +109,7 @@ class RecordingPushRegistrar implements PushRegistrar { final TokenStore _store; int registered = 0; int unregistered = 0; + int paused = 0; bool? hadSessionAtUnregister; /// 두면 지우기가 이것이 끝날 때까지 기다린다 @@ -123,6 +124,9 @@ class RecordingPushRegistrar implements PushRegistrar { hadSessionAtUnregister = _store.hasSession; await unregisterGate?.future; } + + @override + void pause() => paused++; } /// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다 diff --git a/flutter/test/support/login_fakes.dart b/flutter/test/support/login_fakes.dart index d4ece5a..6644024 100644 --- a/flutter/test/support/login_fakes.dart +++ b/flutter/test/support/login_fakes.dart @@ -140,6 +140,7 @@ class LoginHarness { WidgetTester tester, { String? refreshToken, List observers = const [], + List overrides = const [], }) async { tester.view.physicalSize = const Size(390 * 3, 1500 * 3); tester.view.devicePixelRatio = 3; @@ -173,6 +174,7 @@ class LoginHarness { overrides: [ ...base.overrides, authenticatorLauncherProvider.overrideWithValue(launcher), + ...overrides, ], child: const App(), ), diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index b1f0cdb..999828f 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -140,6 +140,9 @@ class FakeMailBackend { FakeResponse? deletionFailure; FakeResponse? stateFailure; + /// 있으면 다음 새 메일 확인 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? stateGate; + /// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce]) Completer? listGate; bool listGateOnce = true; @@ -364,7 +367,10 @@ class FakeMailBackend { 'skippedIds': skipped, }); }); - server.on('GET', MailApiPaths.mailState, (_) { + server.on('GET', MailApiPaths.mailState, (_) async { + final Completer? gate = stateGate; + stateGate = null; + await gate?.future; final FakeResponse? failure = stateFailure; if (failure != null) return failure; return FakeResponse.ok({'state': state}); diff --git a/flutter/test/support/provider_harness.dart b/flutter/test/support/provider_harness.dart index 83fc5b1..d2c8603 100644 --- a/flutter/test/support/provider_harness.dart +++ b/flutter/test/support/provider_harness.dart @@ -25,6 +25,9 @@ class ProviderHarness { late final RecordingNotifier notifier = RecordingNotifier(clock); late final RecordingPushRegistrar push = RecordingPushRegistrar(store); + /// 거짓이면 기기 등록을 기록하는 가짜로 바꾸지 않는다 — 세션이 진짜 새 메일 알림 컨트롤러를 부른다(알림 화면 시험) + bool recordPush = true; + /// ProviderScope · ProviderContainer 에 넣을 덮어쓰기 List get overrides => [ tokenStoreProvider.overrideWithValue(store), @@ -32,7 +35,7 @@ class ProviderHarness { clockProvider.overrideWithValue(clock.call), errorNotifierProvider.overrideWithValue(notifier), userAgentProvider.overrideWithValue(kTestUserAgent), - pushRegistrarProvider.overrideWithValue(push), + if (recordPush) pushRegistrarProvider.overrideWithValue(push), ]; /// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너 diff --git a/flutter/test/support/push_fakes.dart b/flutter/test/support/push_fakes.dart new file mode 100644 index 0000000..3045c67 --- /dev/null +++ b/flutter/test/support/push_fakes.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +import 'fakes.dart'; + +/// 새 메일 알림 (서버가 싣는 데이터 `{ module: 'mail', kind: 'new-mail' }`) +const PushMessage kNewMailPush = PushMessage( + target: PushTarget(module: AppModuleId.mail), + kind: PushKinds.newMail, +); + +/// 로그인이 저절로 끝났다는 알림 +const PushMessage kSessionEndedPush = PushMessage( + target: PushTarget(module: AppModuleId.mail), + kind: PushKinds.sessionEnded, +); + +/// 가짜 FCM — 권한 · 등록 · 소식을 시험이 손으로 보낸다. 부른 차례를 [calls] 에 적는다 +class FakePushMessaging implements PushMessaging { + FakePushMessaging({this.available = true}); + + bool available; + + /// 지금 권한 + PushPermission current = PushPermission.notAsked; + + /// [requestPermission] 이 고를 값 — null 이면 창이 뜨지 않는다(지금 권한 그대로) + PushPermission? answer = PushPermission.granted; + int permissionRequests = 0; + + /// [register] 가 끝나면 보낼 FID — null 이면 소식을 보내지 않는다(시험이 [emitFid] 로) + String? fidOnRegister = 'fid-1'; + Object? registerError; + Object? unregisterError; + Completer? registerGate; + Completer? unregisterGate; + + /// 꺼져 있던 앱을 띄운 알림 + PushMessage? initial; + + /// 부른 차례 — 'register' · 'unregister' · 'requestPermission' + final List calls = []; + + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.broadcast(); + + int get registerCalls => + calls.where((String call) => call == 'register').length; + + int get unregisterCalls => + calls.where((String call) => call == 'unregister').length; + + /// FCM 이 FID 를 알린다(등록 · FID 바뀜) + void emitFid(String fid) => _registered.add(fid); + + /// 앱이 앞에 있을 때 알림이 왔다 + void receive(PushMessage message) => _foreground.add(message); + + /// 앱이 뒤에 있을 때 알림을 눌렀다 + void tap(PushMessage message) => _opened.add(message); + + @override + bool get isAvailable => available; + + @override + Future permission() async => current; + + @override + Future requestPermission() async { + permissionRequests++; + calls.add('requestPermission'); + final PushPermission? chosen = answer; + if (chosen != null) current = chosen; + return current; + } + + @override + Future register() async { + calls.add('register'); + await registerGate?.future; + final Object? error = registerError; + if (error != null) throw error; + final String? fid = fidOnRegister; + if (fid != null) scheduleMicrotask(() => _registered.add(fid)); + } + + /// [unregister] 를 부를 때 시험이 볼 것(그때 토큰이 남아 있었나 등) + void Function()? onUnregister; + + @override + Future unregister() async { + calls.add('unregister'); + onUnregister?.call(); + await unregisterGate?.future; + final Object? error = unregisterError; + if (error != null) throw error; + } + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future initialOpen() async { + final PushMessage? message = initial; + initial = null; + return message; + } +} + +/// 가짜 알림창 · 알림 설정 +class FakeNotificationSystem implements NotificationSystem { + NotificationStatus current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 34, + ); + int statusCalls = 0; + int clears = 0; + int settingsOpened = 0; + + /// [openSettings] 가 열었다고 답하는가 + bool opens = true; + + @override + Future status() async { + statusCalls++; + return current; + } + + @override + Future openSettings() async { + settingsOpened++; + return opens; + } + + @override + Future clearNewMail() async => clears++; +} + +/// 메모리 새 메일 알림 값 — 쓴 값을 [values] 로 본다 +class MemoryPushPreferences implements PushPreferences { + MemoryPushPreferences([this.values = PushPreferenceValues.initial]); + + PushPreferenceValues values; + + /// 두면 읽기가 이것이 끝날 때까지 기다린다 + Completer? loadGate; + + @override + Future load() async { + await loadGate?.future; + return values; + } + + PushPreferenceValues _with({ + bool? enabled, + bool? offerDismissed, + bool? pendingUnregister, + Object? lastFid = _keep, + bool? serverRegistered, + }) => PushPreferenceValues( + enabled: enabled ?? values.enabled, + offerDismissed: offerDismissed ?? values.offerDismissed, + pendingUnregister: pendingUnregister ?? values.pendingUnregister, + lastFid: identical(lastFid, _keep) ? values.lastFid : lastFid as String?, + serverRegistered: serverRegistered ?? values.serverRegistered, + ); + + static const Object _keep = Object(); + + @override + Future setEnabled(bool value) async => values = _with(enabled: value); + + @override + Future setOfferDismissed(bool value) async => + values = _with(offerDismissed: value); + + @override + Future setPendingUnregister(bool value) async => + values = _with(pendingUnregister: value); + + @override + Future setLastFid(String? fid) async => values = _with(lastFid: fid); + + @override + Future setServerRegistered(bool value) async => + values = _with(serverRegistered: value); +} + +/// 앞에 보이는지를 시험이 바꾼다 (앱 생명주기 대신) +class FakeForeground extends AppForeground { + @override + bool build() => true; + + void set(bool value) => state = value; +} + +/// 가짜 서버의 새 메일 알림 · 앱 설정 경로 +class FakePushBackend { + FakePushBackend(this.server); + + final FakeServer server; + + /// 받은 등록(PUT 본문 · Bearer) + final List<({Map body, String? bearer})> puts = + <({Map body, String? bearer})>[]; + final List deletes = []; + final List seen = []; + final List configRequests = []; + + /// 두면 그 응답으로 답한다(실패 시험) + FakeResponse? putFailure; + FakeResponse? deleteFailure; + Completer? putGate; + Completer? deleteGate; + + /// 앱 설정의 `data` — [configResponse] 가 있으면 그것으로 답한다 + Map config = { + 'minSupportedVersion': {'android': '1.0.0', 'ios': null}, + 'pushAvailable': true, + }; + FakeResponse? configResponse; + + void install() { + server + ..on('PUT', ApiPaths.pushRegistration, (RequestOptions options) async { + final Object? data = options.data; + puts.add(( + body: data is Map + ? Map.of(data) + : {}, + bearer: bearerTokenOf(options), + )); + await putGate?.future; + return putFailure ?? + FakeResponse.ok({ + 'updatedAt': '2026-11-07T05:00:00.000Z', + }); + }) + ..on('DELETE', ApiPaths.pushRegistration, (RequestOptions options) async { + deletes.add(options); + await deleteGate?.future; + return deleteFailure ?? const FakeResponse(204, null, rawBody: ''); + }) + ..on('DELETE', ApiPaths.pushRegistrationUnseen, (RequestOptions options) { + seen.add(options); + return const FakeResponse(204, null, rawBody: ''); + }) + ..on('GET', ApiPaths.appConfig, (RequestOptions options) { + configRequests.add(options); + return configResponse ?? FakeResponse.ok(config); + }); + } +} + +/// 새 메일 알림 시험대 — 진짜 세션 노티파이어 · 알림 컨트롤러를 가짜 FCM · 알림창 · 서버로 띄운다 +/// ([ProviderHarness] 와 달리 기기 등록을 가짜로 바꾸지 않는다) +class PushHarness { + PushHarness(); + + final FakeServer server = FakeServer(); + final MemorySecureStore secure = MemorySecureStore(); + final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker(); + final ManualClock clock = ManualClock(); + final FakePushMessaging messaging = FakePushMessaging(); + final FakeNotificationSystem notifications = FakeNotificationSystem(); + final MemoryPushPreferences prefs = MemoryPushPreferences(); + late final FakePushBackend push = FakePushBackend(server); + + late final TokenStore store = TokenStore( + secure: secure, + firstRun: firstRun, + clock: clock.call, + ); + late final RecordingNotifier notifier = RecordingNotifier(clock); + + /// 시험마다 더하는 덮어쓰기 + final List extra = []; + + List get overrides => [ + tokenStoreProvider.overrideWithValue(store), + httpClientAdapterProvider.overrideWithValue(server), + clockProvider.overrideWithValue(clock.call), + errorNotifierProvider.overrideWithValue(notifier), + userAgentProvider.overrideWithValue(kTestUserAgent), + appVersionProvider.overrideWithValue('1.0.0'), + pushMessagingProvider.overrideWithValue(messaging), + notificationSystemProvider.overrideWithValue(notifications), + pushPreferencesProvider.overrideWithValue(prefs), + appForegroundProvider.overrideWith(FakeForeground.new), + ...extra, + ]; + + ProviderContainer container() => + ProviderContainer(overrides: overrides, retry: noAutomaticRetry); + + /// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다 — 갱신 · me 는 [kTestEmail] 의 세션으로 답한다 + Future prepare({String? refreshToken}) async { + if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken; + push.install(); + server + ..on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ) + ..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())) + ..on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await store.prepare(); + } +} diff --git a/flutter/test/support/push_screen_harness.dart b/flutter/test/support/push_screen_harness.dart new file mode 100644 index 0000000..3e3d95e --- /dev/null +++ b/flutter/test/support/push_screen_harness.dart @@ -0,0 +1,73 @@ +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fakes.dart'; +import 'mail_fakes.dart'; +import 'mail_harness.dart'; +import 'push_fakes.dart'; + +/// 새 메일 알림 화면 시험대 — 로그인한 앱 전체(메일 시험대)를 띄우고, 세션이 진짜 새 메일 알림 컨트롤러를 부르게 한다. +/// 기기 쪽(FCM · 알림창 · 이 휴대폰의 값)과 서버(`/push-registrations/current` · `/app-config`)는 가짜다. +/// +/// 처음 모습: 안드로이드 14 · 알림 권한 허용 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 — 시험마다 [pump] 전에 바꾼다. +class PushScreenHarness { + PushScreenHarness() { + app.base.recordPush = false; + messaging.current = PushPermission.granted; + app.overrides.addAll([ + appVersionProvider.overrideWithValue(kPushScreenAppVersion), + pushMessagingProvider.overrideWithValue(messaging), + notificationSystemProvider.overrideWithValue(notifications), + pushPreferencesProvider.overrideWithValue(prefs), + ]); + } + + final MailAppHarness app = MailAppHarness(); + final FakePushMessaging messaging = FakePushMessaging(); + final FakeNotificationSystem notifications = FakeNotificationSystem(); + final MemoryPushPreferences prefs = MemoryPushPreferences(); + late final FakePushBackend push = FakePushBackend(app.server); + + FakeServer get server => app.server; + RecordingNotifier get notifier => app.notifier; + + /// 지금 알림 상태 + PushState get state => app.container.read(pushControllerProvider); + + /// 앱을 띄운다 — 세션을 되살리고 알림 등록(FCM · 서버)까지 마친 뒤 [location](없으면 받은편지함)을 그린다 + Future pump(WidgetTester tester, {String? location}) async { + push.install(); + app.mail.seed(kInboxId, 2); + await app.pump(tester, location: location); + await settle(tester); + } + + /// 설정 탭을 연다 + Future openSettings(WidgetTester tester) async { + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(SettingsTexts.home), + ), + ); + await settle(tester); + } +} + +/// 시험 앱의 판 +const String kPushScreenAppVersion = '1.2.0'; + +/// 그리기 · 가짜 서버 응답이 모두 끝날 때까지 — 서버 응답은 시계가 가야 온다(화면이 가만히 있어도) +Future settle(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 2048c45..0e0afee 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index e17c858..9e4353b 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_core flutter_secure_storage_windows url_launcher_windows ) -- 2.52.0 From 08b8144087c924bdbc939d6808adaadca6595998 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 00:46:30 +0900 Subject: [PATCH 28/32] =?UTF-8?q?docs:=20=EC=95=B1=20=EC=83=88=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=C2=B7=20=EC=95=B1=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=EC=9D=84=20=EC=95=B1=20README=20=C2=B7=20flutter=20RE?= =?UTF-8?q?ADME=20=C2=B7=20=EC=8A=A4=ED=82=AC=20=C2=B7=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 디자인 README 에 "새 메일 알림 · 업데이트 · 앱 아이콘" 절(허락 묻기 · 설정 줄 상태 · 받기 · 누르기 · 로그아웃 차례 · "알림이 멈췄습니다"를 보내는 때)을, flutter README 에 Firebase 설정 파일 · 아이콘 만들기 · Play 데이터 보안 신고를, flutter 스킬에 새 행을, 계약 2절 "새 메일 확인"에 앱이 알림을 받았을 때 한 번 묻는 줄을 더했다. 보드 App1Push 의 ⑧ 칸 설명은 "알림이 멈췄습니다"를 넓힌 결정대로 고쳤다(캔버스 v66). Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 12 ++++--- docs/design/app/README.md | 49 ++++++++++++++++++++++++++-- docs/design/webmail/App1Push.dc.html | 4 +-- docs/mail/api-contract.md | 2 ++ flutter/README.md | 26 +++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index e4c0502..a4f1865 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -21,7 +21,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) | | 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` | | 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) | -| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | +| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고, 그 뒤 서버 logout 과 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)를 함께 보낸다(서로 기다리지 않는다). 제한이 풀리면 쥔 비밀번호를 지운다 | | 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | | **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 | | **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 | @@ -29,7 +29,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) | | **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` | | **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` | -| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | +| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | | 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | | ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) | @@ -38,7 +38,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) | | 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 | | 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | -| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 | +| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 | +| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · `AppStoreLauncher` · 글) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). [Play 스토어 열기]는 `market://details` → 웹 주소, 패키지 이름은 `appPackageNameProvider`(main 이 기기 정보로) | +| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보 · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | | 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | | 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 | | 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | @@ -77,12 +79,14 @@ lib/ │ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent │ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판 │ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes) -│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로 +│ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가) +│ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로 │ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry) │ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시 │ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴 │ └── utils/ ← 순수 유틸 함수 (시계 등) ├── features/ +│ ├── app_update/ ← 업데이트 화면 (모듈이 아니다 — 서버의 최소 판보다 낮을 때) │ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다) │ ├── mail/ ← 모듈 — 주소 /mail/... │ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다) diff --git a/docs/design/app/README.md b/docs/design/app/README.md index f0b8f7c..f4d094f 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -174,7 +174,8 @@ - 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12). - **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다. 고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다). - **푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다. + **알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고 + "임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다). - **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤, 서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림. 첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지, @@ -247,8 +248,50 @@ (2차) 자판과 함께 시트가 올라가고 초점 칸이 보이게 스크롤되는지 · 가로 화면 + 자판, 기간 메뉴가 뜨는 자리, 날짜 창의 한국어 머리와 연필(입력) 모드에서 오늘 뒤 날짜를 막는지, 뒤로 가기 · 끌어 닫기 · 가림막에서 고친 값을 버리는지와 닫은 뒤 자판, TalkBack([조건] 펼침 · 기간 단추 · 날짜 칸 값과 오류 · 단추 줄 위 알림 · 닫은 뒤 초점), 큰 글자의 날짜 칸 · 여러 줄 조건 줄, 바뀐 가림막 색(옮기기 시트 포함), 14단계 서버에서 조건만 · 검색어 + 조건(시간대 · 첨부) · from/to 를 실은 강조. -### 새 메일 알림 (푸시) -- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). +### 새 메일 알림 · 업데이트 · 앱 아이콘 (`App1Push` — 2026-11-07 확정) +- **알림 글은 "새 메일 N통"뿐**이다 — 보낸 사람 · 제목은 Google 서버를 거치지 않게 싣지 않는다(잠금 화면도 수만). 새 메일이 더 오면 같은 알림의 수만 바뀌고 쌓이지 않는다. + 앱을 보고 있을 때는 띄우지 않는다 — 받은편지함이 보이면 그 자리에서 바로 새 메일을 확인한다. 앱으로 돌아오면 알림창의 새 메일 알림을 지운다. + 누르면 늘 메일 탭의 받은편지함이다(읽기 · 쓰기 화면 위에 있었어도 — 쓰던 메일은 임시보관함에 마저 저장하고 알린다). 알림 데이터에는 모듈 이름과 종류만 싣는다. +- **로그인이 끝났다는 알림**: "새 메일 알림이 멈췄습니다" · "안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요."(보드 ⑧ "로그인이 저절로 끝났을 때"의 글). + 보내는 때는 2026-11-08 사용자 결정으로 넓어졌다 — 표의 원본은 서버 `modules/push/push.constants.ts` 의 `SESSION_ENDED_NOTICE` 이고, 여기에는 뜻만 적는다. + - 보낸다: 저절로 끝남(14일 동안 쓰지 않음 · 로그인한 지 30일) · 다른 곳에서 비밀번호를 바꿈(바꾸다 결과를 알 수 없게 된 때도) · 앱 로그인 갱신 유예로 끊김 · 리프레시 토큰 재사용을 알아채 끊김. + - 보내지 않는다: 그 휴대폰에서 로그아웃 · 로그인된 기기에서 끊기 · 다른 곳 모두 로그아웃 · 운영 도구(재설정 · 잠금 · 이전 마치기 · 퇴사 마무리) · 계정을 쓸 수 없음 · 2단계 인증을 켜서 끊긴 세션(제한된 세션). + - 서버가 끝난 세션을 5분마다 정리할 때 한 번 보내고, 같은 tag 로 앞의 "새 메일 N통"을 바꿔 쓴다. 누르면 다른 알림처럼 메일 탭으로 가고, 세션이 없으니 첫 요청에서 로그인 화면이 된다(앱은 알림의 종류로 가르지 않는다). +- **허락은 받은편지함에서 한 번 권한다**(①): 안드로이드 13 이상 · 아직 묻지 않음 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 · 닫은 적 없음일 때만 보인다. 위 막대 아래에 고정이고(목록과 함께 흐르지 않는다) 메일을 고르는 동안은 숨긴다. + 휴대폰이 묻는 창(②)은 [알림 켜기]를 누를 때만 띄운다 — 두 번 거절하면 휴대폰이 다시 묻지 않으므로 직원이 고른 때에만 쓴다. 안드로이드 12 이하는 묻지 않고 처음부터 켜져 있다. + 고른 뒤(③): 허용 → "새 메일 알림을 켰습니다." · 허용 안 함 → "나중에 [설정] 탭에서 …" · 이미 막혀 창이 뜨지 않음 → 알림 줄 + [설정 열기]. 어느 쪽이든 줄은 사라지고, [✕]로 닫으면 이 휴대폰에서 다시 보이지 않는다. +- **설정 › 새 메일 알림 줄**(④ — 그 밖의 칸 · 로그아웃 확인 창은 `App1Shell`): + + | 상태 | 스위치 | 그 밖 | + | --- | --- | --- | + | 켜짐 · 꺼짐 | 누를 수 있음 | — | + | 허락 전(13 이상 — 묻지 않음 · 한 번 거절) | 꺼짐 · 누를 수 있음 | 켜면 휴대폰이 묻는다 | + | 켜는 중 · 끄는 중 | 바꿀 값 쪽 · 다시 받지 않음 | 둥근 진행 표시 — 화면 낭독기는 "켜는 중" · "끄는 중" | + | 휴대폰에서 이 앱 알림이 막힘 | 누를 수 없음 | [설정 열기] → 이 앱의 알림 설정 | + | 휴대폰에서 새 메일 카테고리만 꺼짐 | 누를 수 없음 | [설정 열기] → 그 카테고리 | + | 서버가 보낼 수 없음 · 받을 수 없는 휴대폰(Google Play 서비스 없음 등) | 누를 수 없음 | 앱을 열어 확인해 달라는 글 | + + 켜기는 서버 등록이 끝나야 켜지고(실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구 — 처음 값이 켬인 휴대폰이 허락 전에서 켰어도 꺼짐으로 둔다), 끄기는 서버 등록을 지운 뒤 꺼진다. 휴대폰 설정에서 돌아오면 다시 읽어 모습을 바꾼다. + [설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다(④ — 보드의 "설정을 열지 못했을 때"). 화면 낭독기는 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다. +- **휴대폰 설정의 카테고리**(⑫): 이름 "새 메일" · 설명 "새 메일이 오면 알려 드립니다."만 앱이 정한다. 중요도 높음 — 다른 앱을 쓰는 중에는 화면 위에 잠깐 떴다가 상태 표시줄에 남는다(소리 · 진동은 휴대폰 설정). +- **작은 아이콘**(⑨): ARA 글자 마크의 흰 실루엣(휴대폰은 모양만 쓰고 색은 직접 칠한다 — 알림 앞 동그라미는 강조 #0E7C86). 상태 표시줄에서 가늘다는 걱정이 있어 실기기에서 다시 본다 — + 봉투로 바꾸려면 같은 이름(`ic_stat_ara`)의 그림만 바꾼다(앱 매니페스트의 기본 아이콘이 이 이름 하나를 가리킨다 — 서버 메시지는 아이콘 · 색을 싣지 않는다). +- **앱 아이콘**(⑪ — 나안): 남색 #0B3640 바탕 + 흰 ARA 마크. 적응형(배경색 · 앞면 마크 · 안드로이드 13 테마 아이콘용 마크만 담은 그림 한 장 더)이고 마크는 잘리지 않는 가운데 칸 안에 둔다. + 안드로이드 7(적응형 전)의 아이콘도 같은 모양이다. 삼성 휴대폰은 알림창에도 앱 아이콘을 쓴다. +- **업데이트 화면**(⑩): 이 앱의 판이 서버가 정한 최소 판보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 온다(쓰던 메일은 임시보관함에 저장한다). 뒤로 가기는 앱을 닫는다. + [Play 스토어 열기]는 Play 스토어 앱부터, 열리지 않으면 웹 주소. 문의 줄은 로그인 화면과 같은 연락처다. 서버가 최소 판을 내리면 다음 확인(앱으로 돌아올 때 — 1시간마다)에서 원래 화면으로 돌아간다. +- **로그아웃**: 확인 창이 이 휴대폰에 새 메일 알림도 오지 않는다고 알린다(`App1Shell`). 토큰을 지운 뒤 서버 logout 과 함께 이 휴대폰의 알림을 멈춘다(알림창 지우기 · FCM 등록 풀기 — 서로 기다리지 않는다). + 서버 줄은 서버가 세션과 함께 지운다. 끊긴 망이라 FCM 등록을 풀지 못했으면 다음 실행 · 다음에 앱으로 돌아올 때 다시 풀고, 그사이 같은 휴대폰에서 다른 직원이 로그인하면 그 등록이 이어받는다(같은 FID 로 올려 서버의 앞 직원 줄이 지워진다). +- 글자는 테마 크기로 맞췄다(15.5 → 16 · 14 → 15 · 13.5 → 13 · 13 → 13 — 위 「쓰기」와 같은 정리). +- **구현하며 정한 것**(2026-11-07 — 보드에 없는 글 · 경우) + - 권하기 줄의 [알림 켜기]를 눌렀는데 새 메일 카테고리만 꺼져 있으면: "휴대폰 설정에서 새 메일 알림이 꺼져 있습니다." + [설정 열기](막혔을 때와 같은 꼴). + - 끄는 중의 화면 낭독기 글 "끄는 중"(보드에는 켜는 중만 있다). + - 문의 연락처를 정하지 않았거나 받지 못했을 때: 설정 › 문의 "앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요." · 업데이트 화면 "Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요."(로그인 화면의 문의 줄과 같은 꼴). + - 서버가 보낼 수 있는지 아직 모르면(앱 설정을 받기 전 · 받지 못함) 쓸 수 있는 것으로 본다 — 막지 않는다. 앱 설정은 앱을 띄우자마자 세션 되살리기와 나란히 묻는다. +- **실기기 · 실제 Firebase 에서 볼 것**: FID 등록 소식(`onRegistered`)이 자동 초기화를 끈 채 오는지 · 앱 데이터를 지운 뒤 새 FID, 같은 알림을 바꿔 쓰는지(태그 `new-mail`)와 앱으로 돌아올 때 지워지는지, + 앱을 보는 중에는 시스템 알림이 뜨지 않는지, 뒤에 있을 때 · 꺼져 있을 때 누른 알림이 받은편지함을 여는지, 두 번 거절한 뒤 스위치가 [설정 열기] 쪽으로 바뀌는지, + 카테고리만 끈 상태를 읽는지 · [설정 열기]가 그 카테고리를 여는지(삼성 · Pixel), 상태 표시줄의 작은 아이콘 굵기 · 삼성 알림창의 앱 아이콘 · 테마 아이콘, 절전(Doze) 중 도착 시간, + Play 스토어가 없는 휴대폰의 [Play 스토어 열기], TalkBack(권하기 줄 묶음 · 스위치 이름과 설명 · 켜는 중 알림). ### 처음 판에서 미룬 것 서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저). diff --git a/docs/design/webmail/App1Push.dc.html b/docs/design/webmail/App1Push.dc.html index d42368d..be46dbf 100644 --- a/docs/design/webmail/App1Push.dc.html +++ b/docs/design/webmail/App1Push.dc.html @@ -409,8 +409,8 @@ a{color:#0E7C86}a:hover{color:#0A5F67}
-

8. 로그인이 저절로 끝났을 때

-

앱을 14일 동안 열지 않았거나 로그인한 지 30일이 지나면 한 번 옵니다. ‘새 메일 N통’ 자리를 바꿔 씁니다.

+

8. 로그인이 끝났을 때

+

저절로 끝났거나 다른 곳에서 비밀번호를 바꿔 끊겼을 때 한 번 옵니다. ‘새 메일 N통’ 자리를 바꿔 씁니다.