feat(flutter): 앱 로그인 · 토큰 갱신 · 모듈 라우터 기반을 만든다

앱 1단계의 바탕이다(계약 1절 "앱 로그인" — 서버는 다음 차례, 지금은 가짜 서버로 시험한다).

- 토큰: 리프레시 토큰은 보안 저장소에만, 액세스 토큰은 메모리에만. 첫 실행 표시가 없으면 남은 토큰을
  지우고, 안드로이드 백업을 끈다. 보안 저장소 일은 차례로 한다
- 인터셉터: 401 이면 갱신 한 번(동시 401 묶음) 뒤 다시 보내기, AUTH_010 은 me 를 다시 받아 그 단계로,
  미리 갱신 30초 전 · 만료는 응답 Date 로, 세션 세대가 바뀐 뒤의 응답은 조용히 버린다,
  비밀이 든 요청의 실패 예외는 본문 · Bearer 를 지운다
- 세션: 로그인 · 코드 · 새 비밀번호 · 2단계 켜기 단계의 상태와 늦은 결과 버리기(버린 로그인의 토큰은
  조용히 logout), 로그아웃은 메모리부터 비운다, 유예 응답이면 세션 끝 + 조용한 logout
- 모듈 틀: go_router StatefulShellRoute 와 아래 탭 막대 — 모듈 목록은 웹 app-modules.ts 와 짝,
  첫 판은 [메일 · 설정]. 모듈끼리 import 하지 않는다
- Riverpod 자동 다시 하기를 끈다, Pretendard 1.3.9(OFL), 확정 글자 크기, 시험 126건
- 판은 Flutter 3.41.9(Dart 3.11.5) — pubspec 의 sdk 하한과 lock 을 함께

리뷰 세 번 반영(AUTH_010 다시 보내기 · 늦은 응답 · 저장 실패 · 로그아웃 차례 · 비밀이 실린 객체 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:11:28 +09:00
parent 5823f600aa
commit 35fcf5e4ef
82 changed files with 7339 additions and 324 deletions
+63 -11
View File
@@ -1,17 +1,69 @@
# tma_app
# ARA SYSTEM 앱 (Flutter)
A new Flutter project.
ARA SYSTEM 그룹웨어의 모바일 앱이다. 식별자 `kr.it.arasystem.app` · 이름 "ARA SYSTEM", 안드로이드 먼저(Play 내부 테스트).
## Getting Started
- 구조 · 구현 규칙: [`.claude/skills/flutter/SKILL.md`](../.claude/skills/flutter/SKILL.md)
- 앱 로그인(앱 전용 토큰 경로 — 서버는 아직 없다): 계약 [`docs/mail/api-contract.md`](../docs/mail/api-contract.md) 1절 "앱 로그인"
- 화면 기준: [`docs/design/app/README.md`](../docs/design/app/README.md) (보드 사본 `docs/design/webmail/App1*.dc.html`)
This project is a starting point for a Flutter application.
이 문서는 개발 PC · 배포에서 알아 둘 사실만 적는다.
A few resources to get you started if this is your first Flutter project:
## 개발 실행
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
```bash
cd flutter
cp .env.example .env # 없으면 analyze 가 asset 경고를 낸다
flutter pub get
flutter analyze # 0건이어야 한다
flutter test
flutter run # 에뮬레이터 · USB 실기기
```
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
- **API 주소는 `.env` 의 `API_URL`** 하나다(루트 env 를 따르지 않는다). `.env` 는 앱 안에 그대로 들어가므로 비밀은 넣지 않는다.
- Android 에뮬레이터: `http://10.0.2.2:3000/api` — 에뮬레이터의 10.0.2.2 가 PC 의 127.0.0.1 이다.
- USB 실기기: `adb reverse tcp:3000 tcp:3000` 뒤 `http://localhost:3000/api`. 개발 백엔드는 `127.0.0.1` 에만 열려 있어 같은 와이파이의 휴대폰은 닿지 않는다(docker 스킬 "호스트 포트").
- 개발 PC 의 안드로이드 도구: Android Studio 의 JDK 21 · SDK 36 · NDK 28.2(Flutter 기본)로 빌드된다. SDK 라이선스 동의(`flutter doctor --android-licenses`)는 사람이 직접 한다.
## Flutter 판
**3.41.9 stable (Dart 3.11.5)** 으로 고정이다 — 개발 PC 와 CI 가 같은 판이어야 한다. 판을 바꿀 때 함께 고치는 곳:
- `.github/workflows/ci-flutter.yml` 의 `flutter-version` (CI 는 `flutter pub get --enforce-lockfile` — lock 그대로)
- `pubspec.yaml` 의 `environment.sdk`, `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로)
- `.claude/skills/docker/SKILL.md` 이미지 버전 표의 "Flutter SDK" 줄 · flutter 스킬 "Flutter 판" · 이 문서
- 개발 PC 의 Flutter 는 다른 프로젝트도 함께 쓴다 — 올리기는 사용자가 정한다.
## 빌드
```bash
flutter build apk --debug # 2~3분 · 약 160MB (build/ 는 git 이 무시한다)
flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 키가 먼저다
```
- release 매니페스트(`android/app/src/main/AndroidManifest.xml`)에 INTERNET 권한이 있어야 네트워크가 된다(flutter create 기본은 debug · profile 에만 둔다).
## 배포 준비
- **release 서명 키는 저장소 밖의 비밀이다.** 지금 `build.gradle.kts` 의 release 는 debug 키로 서명한다 — 배포 전에 업로드 키(keystore)를 만들고 `key.properties` 로 읽게 한다. keystore · `key.properties` 는 커밋하지 않고(`android/.gitignore` 가 무시한다) 비밀번호 관리자에 둔다(잃으면 같은 앱으로 업데이트를 올릴 수 없다 — Play 앱 서명을 켜 두면 업로드 키는 다시 받을 수 있다).
- **안드로이드: Play 내부 테스트** — Play Console 계정(1회 25달러, 법인 계정은 D-U-N-S 번호), 테스터 100명까지 메일 목록으로 · 심사 없이 · 업데이트가 저절로. 식별자(`kr.it.arasystem.app`)는 올린 뒤 바꿀 수 없다.
APK 를 직접 나눠 주는 길도 있지만 Google 의 "인증된 개발자만 설치" 정책(2026-09 부터 나라별)이 한국에 언제 걸리는지 확인이 필요하다.
- **iOS: 맥 · Apple 개발자 계정이 먼저다** — 빌드 · 서명 · 시뮬레이터가 모두 맥(Xcode)에서만 되고, 배포 · 푸시(APNs 키)는 Apple Developer Program(연 99달러, 법인은 D-U-N-S)이 든다. 내부 배포는 TestFlight 내부 테스터(빌드 90일 만료).
맥에서 처음 빌드할 때 `ios/Runner/Info.plist` 에 `CFBundleLocalizations`(`ko`)가 있는지 확인한다 — 없으면 복사 · 붙여 넣기 같은 시스템 글이 한국어로 나오지 않는다. bundle id 는 `kr.it.arasystem.app` 으로 맞춰 두었다(맥에서 확인 전).
## 보안 메모
- **토큰**: 리프레시 토큰만 `flutter_secure_storage`(Android Keystore · iOS Keychain), 액세스 토큰은 메모리. 비밀번호 · 토큰을 로그에 남기지 않는다(`LogInterceptor` 를 넣지 않는다).
- **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다.
- **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`).
- 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다.
## 나중에 넣을 때 주의
- **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` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다).
## 라이선스
글꼴 Pretendard 1.3.9(SIL OFL 1.1 — `assets/licenses/pretendard-ofl.txt`)는 앱의 [설정] → [앱 정보] → [오픈소스 라이선스]에 보인다.
@@ -1,8 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- API 서버에 닿으려면 release 판에도 있어야 한다 (debug · profile 매니페스트에만 있던 것을 옮겼다) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!--
백업 끔: 리프레시 토큰은 flutter_secure_storage 가 이 기기의 Keystore 키로 암호화해 둔다.
Google 드라이브 백업 · 기기 옮기기로 다른 기기에 복원되면 키가 없어 읽지 못하고(플러그인 README),
첫 실행 표시(shared_preferences)까지 복원되면 남은 토큰 지우기가 돌지 않는다. 앱에 오프라인 저장은 없다.
-->
<application
android:label="ARA SYSTEM"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12 이상의 백업 · 기기 옮기기 규칙 — 모두 뺀다 (AndroidManifest.xml 의 백업 끔과 같은 까닭).
allowBackup="false" 는 Android 12 이상에서 기기 옮기기(device-transfer)를 막지 못해 따로 적는다.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
with Reserved Font Name Pretendard.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+31
View File
@@ -0,0 +1,31 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/router/app_router.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱이 쓰는 말 — 한국어만 (복사 · 붙여 넣기 메뉴 · 날짜 고르기 같은 시스템 글도 한국어)
const Locale kAppLocale = Locale('ko');
/// 앱 뿌리 — 테마 · 한국어 · 라우터 · 전역 알림 자리
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
title: kAppName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
locale: kAppLocale,
supportedLocales: const <Locale>[kAppLocale],
// Material · Cupertino · Widgets 셋 — flutter_localizations
localizationsDelegates: GlobalMaterialLocalizations.delegates,
// API 실패 알림이 이 키 하나로 뜬다 (core/api/error_notifier.dart)
scaffoldMessengerKey: ref.watch(scaffoldMessengerKeyProvider),
routerConfig: ref.watch(routerProvider),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// `.env` 의 API 주소 키 (`flutter/.env.example`)
const String kApiUrlEnvKey = 'API_URL';
/// `.env` 가 없을 때의 주소 — 백엔드 글로벌 prefix `/api` 를 포함한다.
/// Android 에뮬레이터에서는 `.env` 에 `http://10.0.2.2:3000/api` 를 넣는다.
const String kDefaultApiUrl = 'http://localhost:3000/api';
/// 연결 · 받기 한도
const Duration kApiConnectTimeout = Duration(seconds: 15);
const Duration kApiReceiveTimeout = Duration(seconds: 15);
/// API 연결 설정
class ApiConfig {
const ApiConfig({
required this.baseUrl,
this.connectTimeout = kApiConnectTimeout,
this.receiveTimeout = kApiReceiveTimeout,
});
/// `.env` 를 읽어 만든다 (main 에서 dotenv 를 먼저 읽는다 — 없으면 기본 주소)
factory ApiConfig.fromEnv() {
final String? fromEnv = dotenv.isInitialized
? dotenv.maybeGet(kApiUrlEnvKey)
: null;
final String baseUrl = (fromEnv == null || fromEnv.isEmpty)
? kDefaultApiUrl
: fromEnv;
return ApiConfig(baseUrl: baseUrl);
}
final String baseUrl;
final Duration connectTimeout;
final Duration receiveTimeout;
}
@@ -0,0 +1,91 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
/// 실패를 에러 코드가 정해진 [ApiException] 으로 바꾼다 (에러 코드 규칙 "받는 방법").
///
/// - 표준 실패 본문(`{ success: false, error: { code, traceId } }`)이 있으면 그 코드.
/// nginx 가 직접 끝낸 실패도 이 모양이다(`message` 는 비어 올 수 있다 — 문구는 사전에서만 온다).
/// - 본문이 없으면 HTTP 상태로 거꾸로 맞춘다. 응답이 아예 없으면(연결 끊김 · 시간 초과) `SYS_001`.
/// - 세션 만료 승격(`AUTH_002`)은 여기서 하지 않는다 — 갱신을 해 본 인증 인터셉터만 정한다.
ApiException toApiException(DioException error) {
final Object? existing = error.error;
if (existing is ApiException) return existing;
final Response<Object?>? response = error.response;
if (response == null) return ApiException.fromCode(kFallbackCode);
final ({String? code, String? traceId}) body = readErrorBody(response.data);
return ApiException.fromCode(
body.code ?? codeForStatus(response.statusCode),
traceId: body.traceId,
statusCode: response.statusCode,
);
}
/// 마지막 폴백 코드 — 사전의 폴백과 같다
const String kFallbackCode = 'SYS_001';
/// HTTP 상태 → 에러 코드 (서버가 표준 실패 본문을 주지 못한 경우)
String codeForStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 413:
return 'FILE_002';
case 429:
return 'REQ_001';
default:
return kFallbackCode;
}
}
/// 표준 실패 본문에서 코드 · 추적 ID 만 꺼낸다
({String? code, String? traceId}) readErrorBody(Object? payload) {
if (payload is! Map<String, Object?>) return (code: null, traceId: null);
final Object? error = payload['error'];
if (error is! Map<String, Object?>) return (code: null, traceId: null);
final Object? code = error['code'];
final Object? traceId = error['traceId'];
return (
code: code is String && code.isNotEmpty ? code : null,
traceId: traceId is String && traceId.isNotEmpty ? traceId : null,
);
}
/// 실패에서 에러 코드만 — 화면 · 노티파이어가 `code` 로 가를 때
String? apiErrorCodeOf(Object error) {
if (error is ApiException) return error.code;
if (error is DioException) return toApiException(error).code;
return null;
}
/// 앱이 스스로 정한 실패도 같은 꼴(`DioException` · `error` 에 [ApiException])로 만든다.
///
/// 저장소 · 세션 노티파이어의 실패는 늘 이 꼴이다 — 응답 모양이 계약과 다르거나(서버 쪽 문제)
/// 받은 토큰을 보안 저장소에 두지 못한 경우도. 화면은 `on DioException` 하나로 받아 `apiErrorCodeOf` 로 가른다.
DioException apiFailure(
RequestOptions requestOptions,
String code, {
Response<Object?>? response,
}) {
final ({String? code, String? traceId}) body = readErrorBody(response?.data);
return DioException(
requestOptions: requestOptions,
response: response,
type: response == null
? DioExceptionType.unknown
: DioExceptionType.badResponse,
error: ApiException.fromCode(
code,
traceId: body.traceId,
statusCode: response?.statusCode,
),
);
}
+17
View File
@@ -0,0 +1,17 @@
/// API 경로 — baseUrl(`…/api`) 뒤에 붙는다. 경로는 대소문자를 가린다(계약 머리말).
abstract final class ApiPaths {
// ── 앱 로그인 (계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — 서버는 아직 없다) ──
static const String appLogin = '/auth/app/login';
static const String appRefresh = '/auth/app/refresh';
static const String appLogout = '/auth/app/logout';
/// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다
static const String me = '/auth/me';
/// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일")
static const Set<String> noRefresh = <String>{
appLogin,
appRefresh,
appLogout,
};
}
+371
View File
@@ -0,0 +1,371 @@
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/request_flags.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
/// 세션 제한 코드 — 제한된 세션으로 허용하지 않은 API 를 불렀다 (계약 6절)
const String kRestrictedSessionCode = 'AUTH_010';
/// 확인하지 못한(unknown) 제한 다시 맞추기를 같은 토큰 세대에서 다시 쓰는 시간 — 장애 동안 줄 선 `AUTH_010` 마다 묻지 않게
const Duration kRestrictionCheckReuse = Duration(seconds: 5);
/// 인증 인터셉터 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일".
///
/// - Bearer 를 붙인다. 액세스 토큰이 없거나 곧 끝나면 보내기 전에 갱신한다.
/// - 401 이면 갱신 한 번 뒤 원래 요청을 한 번 다시 보낸다(앱 login · refresh · logout 은 제외).
/// - 403 `AUTH_010` 이면 세션 노티파이어에 제한을 다시 맞춰 달라고 하고, 옛 토큰이었으면(`renewed`) 한 번 다시 보낸다.
/// 제한을 확인했거나 세션이 바뀌었으면 알리지 않고, 확인하지 못했거나 다시 보낸 요청도 막히면 알린다(웹 `recheckRestriction` 과 같은 결).
/// 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다 — 그사이 새 토큰을 받았으면 묻지 않고 다시 보내고, 제한을 확인한 결과는 그 세대 동안 다시 쓴다.
/// - **다시 보낸 요청의 실패도 첫 요청과 같은 규칙을 탄다**(웹은 다시 보낸 요청이 인터셉터를 다시 지난다 — 401 · `AUTH_010` 마다 한 번씩만 다시 보낸다).
/// - **그 요청을 보낸 세션이 끝났으면**(로그아웃 · 세션 끝 — 세션 세대가 다르다) 어떤 실패든 조용히 끝낸다.
/// 다른 직원이 그사이 로그인했어도 그 토큰으로 다시 보내지 않는다. 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`.
///
/// `QueuedInterceptor` 라 요청 · 오류를 차례로 다룬다. 갱신 · 세션 확인 · 다시 보내기는 이 인터셉터가 없는 [_retryDio] 로 한다 —
/// 같은 Dio 로 보내면 그 요청의 오류가 지금 처리 중인 오류 뒤에 줄을 서서 서로 기다리게 된다.
/// 처리기(handler)는 뜻밖의 예외에도 꼭 한 번 부른다 — 부르지 않으면 Dio 의 줄이 영구히 선다.
/// 알림을 건너뛸 실패는 `handler.reject`, 알릴 실패는 `handler.next` 로 끝내고, 내보내기 전에 토큰 · 비밀 본문을 지운다([withoutSecrets]).
class AuthInterceptor extends QueuedInterceptor {
AuthInterceptor({
required TokenStore store,
required TokenRefresher refresher,
required Dio retryDio,
required SessionEventBus events,
Clock clock = systemClock,
}) : _store = store,
_refresher = refresher,
_retryDio = retryDio,
_events = events,
_clock = clock;
final TokenStore _store;
final TokenRefresher _refresher;
final Dio _retryDio;
final SessionEventBus _events;
final Clock _clock;
({int generation, RestrictionCheck check, DateTime at})? _lastCheck;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
DioException? failure;
bool notify = true;
try {
if (!_isNoRefreshPath(options)) {
// 액세스 토큰이 없거나(앱을 새로 띄움) 곧 끝나면 보내기 전에 갱신한다 — 401 을 한 번 덜 본다
if (_store.hasSession && !_store.hasUsableAccessToken) {
final RefreshOutcome outcome = await _refresher.refresh(
sentGeneration: _store.generation,
);
final ApiException? refreshFailure = _failureOf(outcome);
if (refreshFailure != null) {
failure = _rejectedBeforeSend(options, refreshFailure);
// 그사이 로그아웃했으면(Discarded) 알림 인터셉터를 건너뛴다
notify = outcome is! RefreshDiscarded;
}
}
if (failure == null) _attach(options);
}
} on Object catch (error, stackTrace) {
failure = _unexpected(options, error, stackTrace);
}
if (failure == null) {
handler.next(options);
} else {
handler.reject(withoutSecrets(failure), notify);
}
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
_Outcome outcome;
try {
outcome = await _settle(err);
} on Object catch (error, stackTrace) {
outcome = _Notify(_unexpected(err.requestOptions, error, stackTrace));
}
switch (outcome) {
case _Resolve(:final Response<Object?> response):
handler.resolve(response);
case _Notify(:final DioException error):
handler.next(withoutSecrets(error));
case _Quiet(:final DioException error):
handler.reject(withoutSecrets(error));
}
}
/// 실패를 끝까지 다룬다 — 다시 보낸 요청이 실패하면 그 실패도 같은 규칙으로 다시 본다(401 · AUTH_010 마다 한 번씩만 다시 보내 끝난다)
Future<_Outcome> _settle(DioException first) async {
DioException current = first;
while (true) {
final _Step step = await _step(current);
switch (step) {
case _Finish(:final _Outcome outcome):
return outcome;
case _SendAgain(:final RequestOptions options):
try {
return _Resolve(await _retryDio.fetch<Object?>(options));
} on DioException catch (retryError) {
current = retryError;
}
}
}
}
Future<_Step> _step(DioException err) async {
final RequestOptions options = err.requestOptions;
final ApiException apiError = toApiException(err);
final DioException failed = err.error is ApiException
? err
: err.copyWith(error: apiError);
final bool unauthorized = err.response?.statusCode == 401;
final bool restricted = apiError.code == kRestrictedSessionCode;
// 그 요청을 보낸 세션이 끝났다 — 로그아웃 · 세션 끝 뒤에 돌아온 응답은 어떤 것이든 조용히 끝낸다
final Object? sentSession = options.extra[kSessionGenerationExtraKey];
if (sentSession is int && sentSession != _store.sessionGeneration) {
return _Finish(
_Quiet(_ofEndedSession(failed, sentSession, unauthorized)),
);
}
if (options.extra[kAuthHandledExtraKey] == true ||
(!unauthorized && !restricted) ||
_isNoRefreshPath(options)) {
return _Finish(_Notify(failed));
}
if (!_store.hasSession) {
// 토큰 없이 보낸 요청(로그인 전)의 401 은 `AUTH_001` 그대로 알린다
if (!_sentWithToken(options)) return _Finish(_Notify(failed));
// 세션 세대를 싣지 않은 채 세션이 끝났다 — 끝난 세션의 응답처럼 다룬다
return _Finish(
_Quiet(_ofEndedSession(failed, _store.sessionGeneration, unauthorized)),
);
}
return restricted
? _stepRestricted(failed)
: _stepUnauthorized(failed, sentSession);
}
/// 401 — 갱신 한 번 뒤 한 번 다시 보낸다. 다시 보낸 요청도 401 이면 세션이 끊겼다
Future<_Step> _stepUnauthorized(
DioException failed,
Object? sentSession,
) async {
final RequestOptions options = failed.requestOptions;
final ApiException apiError = failed.error! as ApiException;
if (options.extra[kAuthRetriedExtraKey] == true) {
// 새 토큰도 거절됐다 — 세션이 끊겼다(다른 곳에서 로그아웃 · 비밀번호 바꾸기 등)
final ApiException expired = await _refresher.endSession(
traceId: apiError.traceId,
sessionGeneration: sentSession is int ? sentSession : null,
);
return _Finish(_Notify(failed.copyWith(error: expired)));
}
final Object? sentGeneration = options.extra[kAuthGenerationExtraKey];
final RefreshOutcome outcome = await _refresher.refresh(
sentGeneration: sentGeneration is int ? sentGeneration : null,
);
final ApiException? refreshFailure = _failureOf(outcome);
if (refreshFailure == null) {
return _SendAgain(_retryOptions(options, kAuthRetriedExtraKey));
}
final DioException withFailure = failed.copyWith(error: refreshFailure);
return _Finish(
outcome is RefreshDiscarded ? _Quiet(withFailure) : _Notify(withFailure),
);
}
/// 403 `AUTH_010` — 제한을 다시 맞춘다. 옛 토큰이었으면 새 토큰으로 한 번만 다시 보낸다.
Future<_Step> _stepRestricted(DioException failed) async {
final RequestOptions options = failed.requestOptions;
if (options.extra[kRestrictionRetriedExtraKey] == true) {
// 다시 보낸 요청도 막혔다 — 다시 맞추지 않고 알린다
return _Finish(_Notify(failed));
}
final Object? sent = options.extra[kAuthGenerationExtraKey];
final int? sentGeneration = sent is int ? sent : null;
if (sentGeneration != null &&
sentGeneration != _store.generation &&
_store.hasUsableAccessToken) {
// 그사이 다른 요청이 제한을 다시 맞춰 새 토큰을 받았다 — 묻지 않고 다시 보낸다
return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey));
}
final RestrictionCheck check =
_recentCheck(sentGeneration) ?? await _events.requestRecheck();
_lastCheck = (
generation: sentGeneration ?? _store.generation,
check: check,
at: _clock(),
);
return switch (check) {
RestrictionCheck.renewed => _SendAgain(
_retryOptions(options, kRestrictionRetriedExtraKey),
),
// 화면이 그 단계로 간다 · 세션이 바뀌었다 — 알리지 않는다
RestrictionCheck.restricted ||
RestrictionCheck.stale => _Finish(_Quiet(failed)),
RestrictionCheck.unknown => _Finish(_Notify(failed)),
};
}
/// 같은 토큰 세대로 방금 맞춘 결과 — 제한을 확인한 것은 그 세대 동안, 확인하지 못한 것은 [kRestrictionCheckReuse] 동안 다시 쓴다
RestrictionCheck? _recentCheck(int? sentGeneration) {
final ({int generation, RestrictionCheck check, DateTime at})? last =
_lastCheck;
if (last == null || sentGeneration == null) return null;
if (last.generation != sentGeneration) return null;
switch (last.check) {
case RestrictionCheck.restricted:
return last.check;
case RestrictionCheck.unknown:
final Duration since = _clock().difference(last.at);
return !since.isNegative && since < kRestrictionCheckReuse
? last.check
: null;
case RestrictionCheck.renewed:
case RestrictionCheck.stale:
return null;
}
}
/// 끝난 세션의 실패 — 401 은 사용자가 끝냈으면 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝났으면 `AUTH_002`
DioException _ofEndedSession(
DioException failed,
int sessionGeneration,
bool unauthorized,
) {
if (!unauthorized) return failed;
final ApiException apiError = failed.error! as ApiException;
final bool byUser =
_store.endedByUser(sessionGeneration) ?? _store.signedOutByUser;
return failed.copyWith(
error: ApiException.fromCode(
byUser ? 'AUTH_001' : kSessionExpiredCode,
traceId: apiError.traceId,
statusCode: apiError.statusCode,
),
);
}
/// 다시 보낼 요청 — 새 토큰을 붙이고 다시 보냈다는 표시([flag])를 단다
RequestOptions _retryOptions(RequestOptions options, String flag) {
final RequestOptions retry = options.copyWith();
retry.extra[flag] = true;
final Object? data = retry.data;
if (data is FormData) {
// 한 번 보낸 FormData 는 다시 쓸 수 없다
retry.data = data.clone();
}
_attach(retry);
return retry;
}
void _attach(RequestOptions options) {
final String? token = _store.accessToken;
if (token != null) {
options.headers[kAuthorizationHeader] = bearerOf(token);
} else {
options.headers.remove(kAuthorizationHeader);
}
options.extra[kAuthGenerationExtraKey] = _store.generation;
options.extra[kSessionGenerationExtraKey] = _store.sessionGeneration;
}
bool _sentWithToken(RequestOptions options) =>
options.headers.containsKey(kAuthorizationHeader);
bool _isNoRefreshPath(RequestOptions options) =>
ApiPaths.noRefresh.contains(options.path);
DioException _rejectedBeforeSend(RequestOptions options, ApiException error) {
options.extra[kAuthHandledExtraKey] = true;
return DioException(
requestOptions: options,
type: DioExceptionType.unknown,
error: error,
);
}
/// 뜻밖의 예외 — 처리기를 꼭 부르려고 `SYS_001` 실패로 바꾼다
DioException _unexpected(
RequestOptions options,
Object error,
StackTrace stackTrace,
) {
options.extra[kAuthHandledExtraKey] = true;
return DioException(
requestOptions: options,
type: DioExceptionType.unknown,
error: ApiException.fromCode(kFallbackCode),
stackTrace: stackTrace,
);
}
/// 갱신 결과가 실패면 원래 요청에 실을 예외, 성공이면 null
ApiException? _failureOf(RefreshOutcome outcome) {
return switch (outcome) {
RefreshSucceeded() => null,
RefreshRejected(:final ApiException error) => error,
RefreshFailed(:final ApiException error) => error,
// 로그아웃한 뒤라 알리지 않는다 — 부른 쪽은 로그인 전 상태로 본다
RefreshDiscarded() => ApiException.fromCode('AUTH_001'),
};
}
}
/// 실패를 다룬 끝 — 응답으로 바꾸기 · 알리기 · 조용히 끝내기
sealed class _Outcome {
const _Outcome();
}
final class _Resolve extends _Outcome {
const _Resolve(this.response);
final Response<Object?> response;
}
final class _Notify extends _Outcome {
const _Notify(this.error);
final DioException error;
}
final class _Quiet extends _Outcome {
const _Quiet(this.error);
final DioException error;
}
/// 한 차례 판단 — 끝내거나, 다시 보낼 요청
sealed class _Step {
const _Step();
}
final class _Finish extends _Step {
const _Finish(this.outcome);
final _Outcome outcome;
}
final class _SendAgain extends _Step {
const _SendAgain(this.options);
final RequestOptions options;
}
+61 -136
View File
@@ -1,148 +1,73 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/auth_interceptor.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/response_interceptor.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_exception.dart';
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
const String kJwtTokenKey = 'jwt_token';
const Duration _kConnectTimeout = Duration(seconds: 15);
const Duration _kReceiveTimeout = Duration(seconds: 15);
/// 앱의 HTTP 클라이언트 — Dio 둘을 같은 설정으로 만든다.
///
/// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는
/// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`)
/// → [ErrorNotifyInterceptor](알림 — 맨 끝).
/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다.
///
/// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙).
class DioClient {
static final DioClient _instance = DioClient._internal();
late Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
DioClient._(this.dio, this.refresher);
factory DioClient() {
return _instance;
}
factory DioClient.create({
required ApiConfig config,
required TokenStore tokenStore,
required SessionEventBus events,
required ErrorNotifier notifier,
required String userAgent,
Clock clock = systemClock,
HttpClientAdapter? httpClientAdapter,
}) {
final BaseOptions options = BaseOptions(
baseUrl: config.baseUrl,
connectTimeout: config.connectTimeout,
receiveTimeout: config.receiveTimeout,
headers: <String, Object?>{'User-Agent': userAgent},
);
DioClient._internal() {
// 백엔드 글로벌 prefix(/api) 와 일치
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
final Dio bare = Dio(options.copyWith());
final Dio main = Dio(options);
if (httpClientAdapter != null) {
bare.httpClientAdapter = httpClientAdapter;
main.httpClientAdapter = httpClientAdapter;
}
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
final TokenRefresher refresher = TokenRefresher(
bareDio: bare,
store: tokenStore,
events: events,
clock: clock,
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onResponse: (response, handler) {
final payload = response.data;
if (payload is Map<String, dynamic> &&
payload.containsKey('success')) {
if (payload['success'] == true) {
// Global Response Wrapper: 데이터 Unwrap
response.data = payload['data'];
return handler.next(response);
}
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
final error = _readErrorObject(payload);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ApiException.fromCode(
error.code ?? 'BIZ_001',
traceId: error.traceId,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) async {
final apiException = await _toApiException(e);
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
return handler.next(e.copyWith(error: apiException));
},
bare.interceptors.add(ResponseInterceptor());
main.interceptors.addAll(<Interceptor>[
ResponseInterceptor(),
AuthInterceptor(
store: tokenStore,
refresher: refresher,
retryDio: bare,
events: events,
clock: clock,
),
);
ErrorNotifyInterceptor(notifier),
]);
return DioClient._(main, refresher);
}
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
Future<ApiException> _toApiException(DioException e) async {
final response = e.response;
/// 화면 · 저장소가 쓰는 Dio
final Dio dio;
if (response == null) {
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
return ApiException.fromCode('SYS_001');
}
String? traceId;
String? code;
if (response.data is Map<String, dynamic>) {
final error = _readErrorObject(response.data as Map<String, dynamic>);
code = error.code;
traceId = error.traceId;
}
code ??= _resolveCodeByStatus(response.statusCode);
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
await _storage.delete(key: kJwtTokenKey);
code = 'AUTH_002';
}
return ApiException.fromCode(
code,
traceId: traceId,
statusCode: response.statusCode,
);
}
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
String _resolveCodeByStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 429:
return 'REQ_001';
default:
return 'SYS_001';
}
}
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
({String? code, String? traceId}) _readErrorObject(
Map<String, dynamic> payload,
) {
final error = payload['error'];
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
final code = error['code'];
final traceId = error['traceId'];
return (
code: code is String ? code : null,
traceId: traceId is String ? traceId : null,
);
}
Dio get dio => _dio;
/// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나)
final TokenRefresher refresher;
}
+72 -6
View File
@@ -1,9 +1,75 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/dio_client.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/app_constants.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dio_client.dart';
// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다.
// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다.
final dioProvider = Provider<Dio>((ref) {
return DioClient().dio;
});
/// 지금 시각 — 시험은 손으로 돌리는 시계로 덮어쓴다
final Provider<Clock> clockProvider = Provider<Clock>((Ref ref) => systemClock);
/// API 연결 설정 (`.env` 의 `API_URL`)
final Provider<ApiConfig> apiConfigProvider = Provider<ApiConfig>(
(Ref ref) => ApiConfig.fromEnv(),
);
/// User-Agent — main 이 기기 정보로 만든 값으로 덮어쓴다(`loadAppDeviceInfo`). 덮지 않으면 제품 이름만.
final Provider<String> userAgentProvider = Provider<String>(
(Ref ref) => kUserAgentProduct,
);
/// 세션 알림 통로 — 인터셉터가 알리고 세션 노티파이어가 듣는다
final Provider<SessionEventBus> sessionEventsProvider =
Provider<SessionEventBus>((Ref ref) {
final SessionEventBus bus = SessionEventBus();
ref.onDispose(bus.close);
return bus;
});
/// 전역 ScaffoldMessenger 키 — `MaterialApp.router(scaffoldMessengerKey:)` 에 건다
final Provider<GlobalKey<ScaffoldMessengerState>> scaffoldMessengerKeyProvider =
Provider<GlobalKey<ScaffoldMessengerState>>(
(Ref ref) => GlobalKey<ScaffoldMessengerState>(),
);
/// API 실패 알림 (같은 코드는 3초 안에 다시 띄우지 않는다)
final Provider<ErrorNotifier> errorNotifierProvider = Provider<ErrorNotifier>(
(Ref ref) => ErrorNotifier(
messengerKey: ref.watch(scaffoldMessengerKeyProvider),
clock: ref.watch(clockProvider),
),
);
/// 시험이 가짜 서버(HttpClientAdapter)를 끼우는 자리 — 앱에서는 null(실제 네트워크)
final Provider<HttpClientAdapter?> httpClientAdapterProvider =
Provider<HttpClientAdapter?>((Ref ref) => null);
/// HTTP 클라이언트 — Dio 와 토큰 갱신을 함께 만든다
final Provider<DioClient> dioClientProvider = Provider<DioClient>(
(Ref ref) => DioClient.create(
config: ref.watch(apiConfigProvider),
tokenStore: ref.watch(tokenStoreProvider),
events: ref.watch(sessionEventsProvider),
notifier: ref.watch(errorNotifierProvider),
userAgent: ref.watch(userAgentProvider),
clock: ref.watch(clockProvider),
httpClientAdapter: ref.watch(httpClientAdapterProvider),
),
);
/// 전역 Dio — 저장소는 `ref.watch(dioProvider)` 로 받는다.
/// 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸려 있다.
final Provider<Dio> dioProvider = Provider<Dio>(
(Ref ref) => ref.watch(dioClientProvider).dio,
);
/// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다
final Provider<TokenRefresher> tokenRefresherProvider =
Provider<TokenRefresher>(
(Ref ref) => ref.watch(dioClientProvider).refresher,
);
+73
View File
@@ -0,0 +1,73 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
/// 같은 코드의 알림을 다시 띄우지 않는 시간 — 웹 `useApi.ts` 의 `DUPLICATE_ALERT_WINDOW_MS` 와 같다
const Duration kDuplicateAlertWindow = Duration(seconds: 3);
/// API 실패 알림 — 앱에 하나, 전역 `ScaffoldMessenger` 키 하나로 띄운다(웹 `showErrorUI` 자리).
///
/// 문구는 에러 코드 사전에서만 온다([ApiException.message]). 다른 알림 모양으로 바꿀 때도 [show] 하나만 바꾼다.
class ErrorNotifier {
ErrorNotifier({
required GlobalKey<ScaffoldMessengerState> messengerKey,
Clock clock = systemClock,
Duration duplicateWindow = kDuplicateAlertWindow,
}) : _messengerKey = messengerKey,
_clock = clock,
_duplicateWindow = duplicateWindow;
final GlobalKey<ScaffoldMessengerState> _messengerKey;
final Clock _clock;
final Duration _duplicateWindow;
String? _lastCode;
DateTime? _lastShownAt;
/// 알린다 — 같은 코드를 [kDuplicateAlertWindow] 안에 다시 받으면 띄우지 않는다
void show(ApiException error) {
final DateTime now = _clock();
final DateTime? lastShownAt = _lastShownAt;
if (_lastCode == error.code &&
lastShownAt != null &&
now.difference(lastShownAt) < _duplicateWindow) {
return;
}
_lastCode = error.code;
_lastShownAt = now;
display(error);
}
/// 실제로 띄운다 — 스낵바 한 줄(앞의 것은 닫는다). 모양을 바꿀 때는 이것만 바꾼다
@protected
void display(ApiException error) {
final ScaffoldMessengerState? messenger = _messengerKey.currentState;
if (messenger == null) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(error.message)));
}
}
/// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다).
///
/// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패
/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다).
class ErrorNotifyInterceptor extends Interceptor {
ErrorNotifyInterceptor(this._notifier);
final ErrorNotifier _notifier;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final Object? error = err.error;
if (err.type != DioExceptionType.cancel &&
error is ApiException &&
!isSilentFor(err.requestOptions, error.code)) {
_notifier.show(error);
}
handler.next(err);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:dio/dio.dart';
/// 요청마다 알림 끄기 — `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록
/// (`{kSilentExtraKey: {'AUTH_004', 'AUTH_006'}}`). 웹 `useApi` 의 `silent` 와 같다.
///
/// 끈 실패도 예외(`DioException.error` 의 `ApiException`)로는 그대로 온다 — 화면이 `code` 로 가른다.
const String kSilentExtraKey = 'silent';
/// 비밀(비밀번호 · 코드 · 리프레시 토큰)이 본문에 든 요청 — 실패 예외에서 본문을 비운다([withoutSecrets]).
/// 화면이 그 예외를 `AsyncValue` 로 상태에 두어도 비밀이 남지 않게. 로그인 · 코드 · 비밀번호 단계 · logout 이 단다.
const String kSecretBodyExtraKey = 'secret.body';
/// `Authorization` 머리 이름
const String kAuthorizationHeader = 'Authorization';
/// Bearer 머리 값
String bearerOf(String accessToken) => 'Bearer $accessToken';
// ── 인증 인터셉터가 쓰는 내부 표시 (화면 · 저장소는 쓰지 않는다) ──
/// 401 → 갱신 뒤 다시 보낸 요청 — 401 으로는 한 번만 다시 보낸다
const String kAuthRetriedExtraKey = 'auth.retried';
/// `AUTH_010` → 제한 다시 맞추기 뒤 다시 보낸 요청 — `AUTH_010` 으로는 한 번만 다시 보낸다
const String kRestrictionRetriedExtraKey = 'auth.restrictionRetried';
/// 보낼 때의 토큰 세대(TokenStore.generation) — 그사이 다른 요청이 갱신했는지 견준다
const String kAuthGenerationExtraKey = 'auth.generation';
/// 보낼 때의 세션 세대(TokenStore.sessionGeneration) — 그 요청을 보낸 세션이 끝났는지 견준다
const String kSessionGenerationExtraKey = 'auth.session';
/// 인증 인터셉터가 이미 결론 낸 실패 — 다시 갱신하지 않는다
const String kAuthHandledExtraKey = 'auth.handled';
/// 알림 끄기 · 비밀 본문 Options 를 만든다
abstract final class ApiOptions {
/// 모든 실패의 알림을 끈다
static Options silent() =>
Options(extra: <String, Object?>{kSilentExtraKey: true});
/// [codes] 의 알림만 끈다 — 화면이 칸 아래에 보이는 실패(예: 로그인의 `AUTH_004`)
static Options silentFor(Set<String> codes) =>
Options(extra: <String, Object?>{kSilentExtraKey: codes});
/// 비밀이 본문에 든 요청 — [silent] 는 `true` 또는 코드 목록(`Set<String>`)
static Options secret({Object? silent, Map<String, Object?>? headers}) =>
Options(
extra: <String, Object?>{
kSecretBodyExtraKey: true,
kSilentExtraKey: ?silent,
},
headers: headers,
);
}
/// 이 요청의 [code] 실패를 알리지 않는가
bool isSilentFor(RequestOptions options, String code) {
final Object? flag = options.extra[kSilentExtraKey];
if (flag == true) return true;
if (flag is Iterable<Object?>) return flag.contains(code);
return false;
}
/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`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);
}
return error;
}
void _scrub(RequestOptions options) {
options.headers.remove(kAuthorizationHeader);
if (options.extra[kSecretBodyExtraKey] == true) options.data = null;
}
@@ -0,0 +1,52 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
/// 표준 응답을 푼다 (응답 포맷 규칙) — 인터셉터 줄의 맨 앞에 둔다.
///
/// - `{ success: true, data }` → `data` 만 남긴다. 스트림 · 바이트(첨부 받기 · 실시간 알림)는 Map 이 아니라 그대로 둔다.
/// - `{ success: false }` 가 200 으로 오면(외부 게이트웨이 등 — 서버는 4xx 로 내린다) 실패로 바꾼다.
/// - 실패는 모두 `DioException.error` 에 [ApiException] 을 싣는다 — 뒤의 인증 · 알림 인터셉터가 코드로 가른다.
class ResponseInterceptor extends Interceptor {
@override
void onResponse(
Response<Object?> response,
ResponseInterceptorHandler handler,
) {
final Object? payload = response.data;
if (payload is! Map<String, Object?> || !payload.containsKey('success')) {
handler.next(response);
return;
}
if (payload['success'] == true) {
response.data = payload['data'];
handler.next(response);
return;
}
final ({String? code, String? traceId}) body = readErrorBody(payload);
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: ApiException.fromCode(
body.code ?? 'BIZ_001',
traceId: body.traceId,
statusCode: response.statusCode,
),
),
// 뒤의 오류 인터셉터(인증 · 알림)도 지나게 한다
true,
);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err.copyWith(error: toApiException(err)));
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:io';
import 'package:dio/dio.dart';
/// 응답의 `Date` 머리(서버 시각) — 없거나 읽지 못하면 null.
///
/// 토큰의 남은 시간을 서버 시계로 재는 데 쓴다(TokenStore — 기기 시계가 틀려도 만료를 맞게 본다).
DateTime? serverTimeOf(Response<Object?> response) {
final String? value = response.headers.value(HttpHeaders.dateHeader);
if (value == null || value.isEmpty) return null;
try {
return HttpDate.parse(value).toUtc();
} on HttpException {
return null;
} on FormatException {
return null;
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
/// 제한(`AUTH_010`)을 다시 맞춘 결과 — 웹 `frontend/src/composables/useApi.ts` 의 `RestrictionCheck` 와 같다.
enum RestrictionCheck {
/// 옛 토큰이었다 — 제한 없이 새 토큰을 받았다. 원래 요청을 한 번 다시 보내도 된다
renewed,
/// 제한을 확인했다 — 세션이 그 단계 화면으로 옮긴다. 알리지 않는다
restricted,
/// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않는다. 알리지 않는다
stale,
/// 확인하지 못했다(장애 · 듣는 쪽 없음) — 원래 실패를 알린다
unknown,
}
/// 제한을 다시 맞추는 데 기다리는 최대 시간 — 세션 확인(me)과 갱신이 안쪽 Dio 의 연결 · 받기 한도
/// (`api_config.dart` — 15초 + 15초)를 다 써도 그 결과가 먼저 오게, 두 요청의 한도 합(60초)에 5초를 더했다.
/// 듣는 쪽이 답하지 않아도 요청이 끝없이 서 있지 않게 하는 안전 한도다. API 한도를 바꾸면 함께 바꾼다(시험이 견준다).
const Duration kRestrictionRecheckTimeout = Duration(seconds: 65);
/// 네트워크 층이 세션 노티파이어에 알리는 일.
///
/// 인터셉터는 세션 노티파이어를 모른다(노티파이어가 Dio 를 쓰므로 서로 물리지 않게) —
/// 여기에 알리기만 하고, 세션 노티파이어가 듣고 상태를 바꾼다.
sealed class SessionEvent {
const SessionEvent();
}
/// 갱신이 거절됐다(401 · 유예 응답) — 토큰은 이미 지웠다. 로그인 화면으로 간다.
final class SessionEnded extends SessionEvent {
const SessionEnded();
}
/// 403 `AUTH_010` — 제한된 세션이다. 알림 없이 세션을 다시 확인해 그 단계로 옮기고 결과를 [reply] 로 돌려준다(계약 6절).
final class SessionRecheckNeeded extends SessionEvent {
SessionRecheckNeeded();
final Completer<RestrictionCheck> _answer = Completer<RestrictionCheck>();
/// 다시 맞춘 결과 — 듣는 쪽이 [reply] 로 채운다
Future<RestrictionCheck> get answer => _answer.future;
/// 결과를 돌려준다 — 처음 한 번만 쓴다
void reply(RestrictionCheck check) {
if (!_answer.isCompleted) _answer.complete(check);
}
}
/// 토큰을 갱신했다 — 응답에 실린 세션 칸(제한 · 유예)으로 화면을 맞춘다(계약 6절 — 갱신 응답에도 늘 싣는다).
final class SessionRefreshed extends SessionEvent {
const SessionRefreshed(this.body);
/// 갱신 응답의 세션 칸만(`email` · `name` · `restriction` · `twoFactorDeadline`) — 토큰은 싣지 않는다
final Map<String, Object?> body;
}
/// 갱신 응답에서 세션 칸만 남긴다 — 토큰(`tokens`)이 알림 · 결과 객체에 실려 다니지 않게
Map<String, Object?> sessionFieldsOf(Map<String, Object?> body) =>
<String, Object?>{
for (final MapEntry<String, Object?> entry in body.entries)
if (kSessionFieldNames.contains(entry.key)) entry.key: entry.value,
};
/// 세션 칸 이름 (계약 1 · 6절)
const Set<String> kSessionFieldNames = <String>{
'email',
'name',
'restriction',
'twoFactorDeadline',
};
/// 세션 알림 통로 — 앱에 하나 (dio_provider 의 sessionEventsProvider)
class SessionEventBus {
SessionEventBus({Duration recheckTimeout = kRestrictionRecheckTimeout})
: _recheckTimeout = recheckTimeout;
final Duration _recheckTimeout;
final StreamController<SessionEvent> _controller =
StreamController<SessionEvent>.broadcast();
Stream<SessionEvent> get stream => _controller.stream;
void emit(SessionEvent event) {
if (!_controller.isClosed) _controller.add(event);
}
/// `AUTH_010` 을 받았다 — 세션 노티파이어에 제한을 다시 맞춰 달라고 하고 그 결과를 기다린다.
/// 듣는 쪽이 없거나 [kRestrictionRecheckTimeout] 안에 답이 없으면 [RestrictionCheck.unknown].
Future<RestrictionCheck> requestRecheck() {
if (_controller.isClosed || !_controller.hasListener) {
return Future<RestrictionCheck>.value(RestrictionCheck.unknown);
}
final SessionRecheckNeeded event = SessionRecheckNeeded();
_controller.add(event);
return event.answer.timeout(
_recheckTimeout,
onTimeout: () => RestrictionCheck.unknown,
);
}
Future<void> close() => _controller.close();
}
+304
View File
@@ -0,0 +1,304 @@
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/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
/// 클라이언트 전용 세션 만료 코드 (에러 코드 규칙 "AUTH_002")
const String kSessionExpiredCode = 'AUTH_002';
/// 갱신이 일시 장애로 실패하면 같은 토큰 세대 동안 이만큼은 그 결과를 다시 쓴다 —
/// 연결이 끊긴 동안 줄 선 요청마다 갱신을 보내 그때마다 연결 한도를 기다리지 않게
const Duration kRefreshFailureReuse = Duration(seconds: 5);
/// 갱신 결과
sealed class RefreshOutcome {
const RefreshOutcome();
}
/// 새 토큰을 받아 두었다 — [body] 는 갱신 응답의 세션 칸만(토큰 없음). 그사이 다른 요청이 이미 받아 두었으면 null.
final class RefreshSucceeded extends RefreshOutcome {
const RefreshSucceeded([this.body]);
final Map<String, Object?>? body;
}
/// 세션이 끝났다 — 서버가 401 로 거절했거나 유예 응답이었다. 토큰은 지웠고 [error] 는 `AUTH_002` 다.
final class RefreshRejected extends RefreshOutcome {
const RefreshRejected(this.error);
final ApiException error;
}
/// 일시 장애(연결 끊김 · 5xx) — 세션은 그대로 두고 [error] 를 알린다 (계약 1절: 로그아웃으로 다루지 않는다)
final class RefreshFailed extends RefreshOutcome {
const RefreshFailed(this.error);
final ApiException error;
}
/// 갱신하는 사이 로그아웃했다 — 받은 토큰은 쓰지 않고 서버 세션도 끝냈다(계약 1절)
final class RefreshDiscarded extends RefreshOutcome {
const RefreshDiscarded();
}
/// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다
/// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`).
/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다.
Future<void> postAppLogout(
Dio dio, {
required String? refreshToken,
required String? accessToken,
}) async {
await dio.post<Object?>(
ApiPaths.appLogout,
data: <String, Object?>{'refreshToken': ?refreshToken},
options: ApiOptions.secret(
silent: true,
headers: <String, Object?>{
if (accessToken != null) kAuthorizationHeader: bearerOf(accessToken),
},
),
);
}
/// 인증 인터셉터 줄 **밖에서** 보내는 인증 요청 — 갱신 · 세션 확인 · 조용한 로그아웃.
///
/// 갱신은 **한 번에 하나만** 한다. 리프레시 토큰은 1회용이라 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이 된다.
/// 인증 인터셉터(요청 전 미리 갱신 · 401 뒤 갱신)와 세션 노티파이어가 모두 이것을 지나
/// 진행 중인 갱신이 있으면 그 결과를 함께 기다린다.
///
/// 요청은 인증 인터셉터가 없는 Dio(`bareDio`)로 보낸다 — 인증 인터셉터는 `QueuedInterceptor` 라
/// 그 오류 처리 안에서 같은 줄로 요청을 보내면 그 요청의 오류가 지금 처리 뒤에 줄을 서서 서로 기다린다.
class TokenRefresher {
TokenRefresher({
required Dio bareDio,
required TokenStore store,
required SessionEventBus events,
Clock clock = systemClock,
}) : _dio = bareDio,
_store = store,
_events = events,
_clock = clock;
final Dio _dio;
final TokenStore _store;
final SessionEventBus _events;
final Clock _clock;
Future<RefreshOutcome>? _inFlight;
({int generation, DateTime at, RefreshFailed outcome})? _lastFailure;
/// 갱신한다.
///
/// [sentGeneration] 은 실패한 요청을 보낼 때의 토큰 세대다. 그사이 다른 요청이 이미 새 토큰을
/// 받아 두었으면 다시 갱신하지 않고 [RefreshSucceeded] 로 답한다.
/// 같은 세대에서 [kRefreshFailureReuse] 안에 일시 장애로 실패했으면 다시 보내지 않고 그 결과를 돌려준다.
Future<RefreshOutcome> refresh({int? sentGeneration}) {
final Future<RefreshOutcome>? inFlight = _inFlight;
if (inFlight != null) return inFlight;
if (sentGeneration != null &&
sentGeneration != _store.generation &&
_store.hasUsableAccessToken) {
return Future<RefreshOutcome>.value(const RefreshSucceeded());
}
final RefreshFailed? recent = _recentFailure();
if (recent != null) return Future<RefreshOutcome>.value(recent);
final Future<RefreshOutcome> run = _runGuarded();
_inFlight = run;
run.whenComplete(() {
if (identical(_inFlight, run)) _inFlight = null;
}).ignore();
return run;
}
/// 일시 장애 기억을 지운다 — 사용자가 [다시 시도]를 눌렀을 때
void forgetFailure() => _lastFailure = null;
/// 세션을 끝낸다 — 토큰을 지우고 세션 노티파이어에 알린다. 돌려주는 예외는 `AUTH_002` 다.
///
/// [sessionGeneration] 을 주면 그 세션이 아직일 때만 지운다 — 그사이 로그아웃했으면(다른 직원이 로그인했어도)
/// 그 세션을 건드리지 않는다.
Future<ApiException> endSession({
String? traceId,
int? sessionGeneration,
}) async {
final ApiException expired = ApiException.fromCode(
kSessionExpiredCode,
traceId: traceId,
statusCode: 401,
);
if (sessionGeneration != null &&
sessionGeneration != _store.sessionGeneration) {
return expired;
}
try {
await _store.clear();
} on PlatformException {
// 메모리의 토큰은 이미 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 다시 지운다
}
_events.emit(const SessionEnded());
return expired;
}
/// 지금 세션(`GET /auth/me`)을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때(인터셉터가 기다리는 중이라).
/// 응답의 `data` 를 돌려주고, 실패는 `DioException`(`error` 에 `ApiException` · 토큰을 지운 것)으로 던진다. 알림은 띄우지 않는다.
Future<Map<String, Object?>> readSession() async {
final String? accessToken = _store.accessToken;
final Response<Object?> response;
try {
response = await _dio.get<Object?>(
ApiPaths.me,
options: Options(
extra: <String, Object?>{kSilentExtraKey: true},
headers: <String, Object?>{
if (accessToken != null)
kAuthorizationHeader: bearerOf(accessToken),
},
),
);
} on DioException catch (error) {
throw withoutSecrets(error);
}
final Object? data = response.data;
if (data is! Map<String, Object?>) {
throw withoutSecrets(
apiFailure(response.requestOptions, kFallbackCode, response: response),
);
}
return data;
}
/// 조용한 앱 logout — 버린 토큰의 세션을 서버에서 끝낸다(닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다)
Future<void> logoutQuietly({
required String? refreshToken,
required String? accessToken,
}) async {
if (refreshToken == null && accessToken == null) return;
try {
await postAppLogout(
_dio,
refreshToken: refreshToken,
accessToken: accessToken,
);
} on DioException {
// 위 설명대로 둔다
}
}
RefreshFailed? _recentFailure() {
final ({int generation, DateTime at, RefreshFailed outcome})? last =
_lastFailure;
if (last == null || last.generation != _store.generation) return null;
final Duration since = _clock().difference(last.at);
if (since.isNegative || since >= kRefreshFailureReuse) return null;
return last.outcome;
}
Future<RefreshOutcome> _runGuarded() async {
RefreshOutcome outcome;
try {
outcome = await _run();
} on PlatformException {
// 보안 저장소를 읽지 못했다 — 세션은 그대로 두고 일시 장애로 알린다
outcome = RefreshFailed(ApiException.fromCode(kFallbackCode));
}
_lastFailure = outcome is RefreshFailed
? (generation: _store.generation, at: _clock(), outcome: outcome)
: null;
return outcome;
}
Future<RefreshOutcome> _run() async {
final int startGeneration = _store.generation;
final int startSession = _store.sessionGeneration;
final String? refreshToken = await _store.readRefreshToken();
// 읽는 사이 로그아웃했다 — 그 세션을 끝내지 않는다(로그아웃이 이미 끝냈다)
if (_store.generation != startGeneration) return const RefreshDiscarded();
if (refreshToken == null) {
return RefreshRejected(await endSession(sessionGeneration: startSession));
}
final Response<Object?> response;
try {
response = await _dio.post<Object?>(
ApiPaths.appRefresh,
data: <String, Object?>{'refreshToken': refreshToken},
options: ApiOptions.secret(silent: true),
);
} on DioException catch (error) {
if (_store.generation != startGeneration) return const RefreshDiscarded();
final ApiException apiError = toApiException(error);
if (error.response?.statusCode == 401) {
return RefreshRejected(
await endSession(
traceId: apiError.traceId,
sessionGeneration: startSession,
),
);
}
return RefreshFailed(apiError);
}
final Object? data = response.data;
final Map<String, Object?> body = data is Map<String, Object?>
? data
: const <String, Object?>{};
final AuthTokens? tokens = AuthTokens.tryParse(
body['tokens'],
serverTime: serverTimeOf(response),
);
if (_store.generation != startGeneration) {
// 갱신하는 사이 로그아웃했다 — 서버가 로그아웃 뒤에 새 토큰을 줄 수 있어 그 세션도 끝낸다
if (tokens != null) {
unawaited(
logoutQuietly(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
),
);
}
return const RefreshDiscarded();
}
if (tokens != null) {
try {
await _store.save(tokens);
} on PlatformException {
// 새 토큰을 보안 저장소에 두지 못했다 — 이번 실행 동안 메모리의 새 토큰으로 이어 가고,
// 다음 저장 · 읽기 때 다시 쓴다(TokenStore). 그 전에 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤
// 재사용으로 세션을 끊는다 — 계약 1절 "앱 로그인"의 알려진 한계와 같은 결이다.
}
final Map<String, Object?> session = sessionFieldsOf(body);
_events.emit(SessionRefreshed(session));
return RefreshSucceeded(session);
}
if (body.containsKey('tokens') && body['tokens'] == null) {
// 30초 유예 응답(새 토큰 없이 200) — 계약 1절 "앱 로그인": 세션 끝으로 다룬다.
// 앱은 갱신을 한 번에 하나만 하므로 앞 갱신의 응답을 잃은 경우이고 이어 쓸 토큰이 없다.
// 메모리의 액세스 토큰이 아직 있으면 그것으로 서버 세션도 끝낸다("로그인된 기기"에 14일 남지 않게 — sid 로).
final String? accessToken = _store.accessToken;
final ApiException expired = await endSession(
sessionGeneration: startSession,
);
if (accessToken != null) {
unawaited(logoutQuietly(refreshToken: null, accessToken: accessToken));
}
return RefreshRejected(expired);
}
// 모양이 틀린 응답 — 서버 쪽 문제로 보고 세션은 그대로 둔다
return RefreshFailed(ApiException.fromCode(kFallbackCode));
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// 기종 · 판 글자의 최대 길이 — 머리글이 지나치게 길어지지 않게
const int _kUserAgentPartMaxLength = 64;
/// 서버가 앱 세션을 알아보는 User-Agent — `ARASYSTEM-App/<판> (Android <판>; <기종>)` (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션").
///
/// 머리글 값은 ASCII 만 보낼 수 있고 `;` `(` `)` 는 꼴을 깨므로, 기기가 준 글자에서 영문 · 숫자 · 공백 · `. _ -` 만 남긴다.
/// 모르는 칸은 뺀다(예: `ARASYSTEM-App/1.0.0 (Android 15)`).
String buildUserAgent({
required String? appVersion,
required String osName,
String? osVersion,
String? model,
}) {
final String version = _clean(appVersion);
final String product = version.isEmpty
? kUserAgentProduct
: '$kUserAgentProduct/$version';
final String os = <String>[
_clean(osName),
_clean(osVersion),
].where((String part) => part.isNotEmpty).join(' ');
final List<String> details = <String>[
os,
_clean(model),
].where((String part) => part.isNotEmpty).toList();
return details.isEmpty ? product : '$product (${details.join('; ')})';
}
String _clean(String? value) {
if (value == null) return '';
final String kept = value
.replaceAll(RegExp(r'[^A-Za-z0-9 ._\-]'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return kept.length > _kUserAgentPartMaxLength
? kept.substring(0, _kUserAgentPartMaxLength).trim()
: kept;
}
/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다.
Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async {
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on PlatformException {
appVersion = null;
}
return (
appVersion: appVersion,
userAgent: await _deviceUserAgent(appVersion),
);
}
Future<String> _deviceUserAgent(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,
);
}
if (Platform.isIOS) {
final IosDeviceInfo ios = await device.iosInfo;
return buildUserAgent(
appVersion: appVersion,
osName: 'iOS',
osVersion: ios.systemVersion,
model: ios.utsname.machine,
);
}
} on PlatformException {
// 아래의 아는 것만 싣는다
}
return buildUserAgent(
appVersion: appVersion,
osName: Platform.operatingSystem,
);
}
@@ -0,0 +1,12 @@
/// 앱 전체에서 쓰는 이름 · 식별 값.
///
/// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 —
/// 스토어에 올린 뒤에는 바꿀 수 없다.
library;
/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름
const String kAppName = 'ARA SYSTEM';
/// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)`
/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다)
const String kUserAgentProduct = 'ARASYSTEM-App';
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱 판(pubspec `version` 의 build-name — 예 `1.0.0`). main 이 기기 정보를 읽어 덮어쓴다.
/// 읽지 못했으면 null — 설정 › 앱 정보 · 라이선스 화면은 판 없이 보인다.
final Provider<String?> appVersionProvider = Provider<String?>(
(Ref ref) => null,
);
@@ -0,0 +1,24 @@
/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다.
/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`).
///
/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고
/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class ScreenTitles {
/// 세션을 되살리는 동안
static const String start = '시작하는 중';
/// 로그인 — 비밀번호 단계
static const String login = '로그인';
/// 로그인 — 2단계 인증 코드 단계
static const String loginCode = '인증 코드 입력';
/// 제한된 세션 — 새 비밀번호 정하기 (passwordChange)
static const String passwordChange = '새 비밀번호 정하기';
/// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup)
static const String twoFactorSetup = '2단계 인증 켜기';
/// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만)
static const String restricted = '로그인 제한';
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
/// 사내 시스템 모듈 목록 — 아래 탭 막대(웹의 전역 앱 레일)는 이 목록 하나만 보고 그린다.
///
/// **짝: 웹 `frontend/src/shared/constants/app-modules.ts` 의 `APP_MODULES`** — 같은 id · 이름 · 차례다.
/// 한쪽을 바꾸면 둘 다 바꾼다(아이콘은 플랫폼마다 따로 — 웹은 lucide, 앱은 Material).
///
/// 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭에 보인다([enabledAppModules]).
/// 새 모듈을 붙이는 법: 여기 항목을 켜고 → `features/<모듈>/<모듈>_routes.dart` 에 라우트 묶음을 만들고 →
/// `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다(빠뜨리면 컴파일 · 시험이 막는다).
enum AppModuleId {
mail,
calendar,
files,
contacts,
settings;
/// 주소 · 알림의 모듈 이름 — 없는 이름이면 null
static AppModuleId? fromName(String? name) {
for (final AppModuleId id in AppModuleId.values) {
if (id.name == name) return id;
}
return null;
}
}
/// 모듈 하나
@immutable
class AppModule {
const AppModule({
required this.id,
required this.label,
required this.icon,
required this.selectedIcon,
required this.isEnabled,
});
final AppModuleId id;
/// 탭 막대 · 화면 머리에 보이는 이름 (웹 `label` 과 같다)
final String label;
final IconData icon;
/// 고른 탭의 아이콘
final IconData selectedIcon;
/// 첫 판에 켜는가 — 꺼진 모듈은 탭 · 주소 · 알림 어디에도 열리지 않는다
final bool isEnabled;
/// 모듈 첫 화면 경로 — 주소는 모듈 이름부터 시작한다(`/mail/...` · `/settings/...`)
String get rootPath => '/${id.name}';
}
/// 모듈 목록 — 웹과 같은 차례(설정은 맨 끝)
const List<AppModule> kAppModules = <AppModule>[
AppModule(
id: AppModuleId.mail,
label: '메일',
icon: Icons.mail_outline,
selectedIcon: Icons.mail,
isEnabled: true,
),
AppModule(
id: AppModuleId.calendar,
label: '캘린더',
icon: Icons.calendar_today_outlined,
selectedIcon: Icons.calendar_today,
isEnabled: false,
),
AppModule(
id: AppModuleId.files,
label: '파일',
icon: Icons.folder_outlined,
selectedIcon: Icons.folder,
isEnabled: false,
),
// 웹은 8단계부터 켜져 있다 — 앱은 첫 판 밖
AppModule(
id: AppModuleId.contacts,
label: '주소록',
icon: Icons.people_outline,
selectedIcon: Icons.people,
isEnabled: false,
),
AppModule(
id: AppModuleId.settings,
label: '설정',
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
isEnabled: true,
),
];
/// 로그인 뒤 첫 모듈 · 모르는 주소가 돌아가는 곳
const AppModuleId kHomeModuleId = AppModuleId.mail;
/// 켜진 모듈만 — 목록 차례 그대로 (탭 막대의 차례)
List<AppModule> enabledAppModules([List<AppModule> modules = kAppModules]) =>
modules.where((AppModule module) => module.isEnabled).toList();
/// id 로 모듈을 찾는다
AppModule findAppModule(
AppModuleId id, [
List<AppModule> modules = kAppModules,
]) => modules.firstWhere((AppModule module) => module.id == id);
/// 경로의 첫 칸으로 켜진 모듈을 찾는다(`/mail/messages/1` → 메일) — 없거나 꺼졌으면 null
AppModule? enabledModuleOfPath(
String path, [
List<AppModule> modules = kAppModules,
]) {
final List<String> segments = Uri.parse(path).pathSegments;
if (segments.isEmpty) return null;
final AppModuleId? id = AppModuleId.fromName(segments.first);
if (id == null) return null;
final AppModule module = findAppModule(id, modules);
return module.isEnabled ? module : null;
}
/// 홈(로그인 뒤 첫 화면 · 모르는 주소가 돌아가는 곳)의 경로 — [modules] 에서 홈 모듈이 켜져 있으면 그 첫 화면,
/// 꺼져 있으면 켜진 첫 모듈의 첫 화면
String homeLocationOf([List<AppModule> modules = kAppModules]) {
final AppModule home = findAppModule(kHomeModuleId, modules);
if (home.isEnabled) return home.rootPath;
final List<AppModule> enabled = enabledAppModules(modules);
return enabled.isEmpty ? home.rootPath : enabled.first.rootPath;
}
/// 기본 목록의 홈 경로 (`/mail`)
String get homeLocation => homeLocationOf();
@@ -0,0 +1,62 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
/// 모듈이 라우터에 내놓는 것 — 모듈마다 `features/<모듈>/<모듈>_routes.dart` 에 하나.
///
/// `core/router` 가 켜진 모듈 목록을 보고 이것들을 모아 탭(StatefulShellBranch)마다 하나씩 붙인다.
/// 모듈끼리는 서로 import 하지 않는다 — 설정 화면에 절을 더할 때도 [settingsSections] 로 내놓기만 한다.
@immutable
class ModuleRoutes {
const ModuleRoutes({
required this.id,
required this.buildRoutes,
this.locationOfItem,
this.settingsSections = const <ModuleSettingsSection>[],
});
final AppModuleId id;
/// 탭 안의 라우트 — 첫 화면은 모듈 경로(`/<모듈>`)여야 한다.
/// 탭 막대 없이 여는 전체 화면(읽기 · 쓰기)은 `parentNavigatorKey: context.rootNavigatorKey` 로 연다.
final List<RouteBase> Function(ModuleRouteContext context) buildRoutes;
/// 알림 · 깊은 링크의 항목 id → 그 화면 경로. 없으면 모듈 첫 화면으로 간다.
final String Function(String itemId)? locationOfItem;
/// 설정 탭에 더할 이 모듈의 절 (예: 나중에 캘린더 알림)
final List<ModuleSettingsSection> settingsSections;
}
/// 라우트를 만들 때 모듈이 받는 것
@immutable
class ModuleRouteContext {
const ModuleRouteContext({
required this.rootNavigatorKey,
this.settingsSections = const <ModuleSettingsSection>[],
});
/// 탭 막대 위에 덮어 여는 전체 화면의 navigator
final GlobalKey<NavigatorState> rootNavigatorKey;
/// 다른 모듈이 내놓은 설정 절 — 설정 모듈만 쓴다
final List<ModuleSettingsSection> settingsSections;
}
/// 설정 탭의 절 하나 — 설정 모듈이 자기 라우트 아래(`/settings/<path>`)에 붙인다
@immutable
class ModuleSettingsSection {
const ModuleSettingsSection({
required this.title,
required this.path,
required this.builder,
});
/// 설정 목록에 보이는 이름
final String title;
/// `/settings/` 아래의 칸 — 예: `calendar`
final String path;
final GoRouterWidgetBuilder builder;
}
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다).
///
/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와
/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다:
/// - 로그인을 마치면(제한 없음) [register]
/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다.
/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다
/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다).
abstract interface class PushRegistrar {
Future<void> register();
/// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다)
Future<void> unregister();
}
/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다
const Duration kPushUnregisterTimeout = Duration(seconds: 5);
/// 지금의 구현 — 아무것도 하지 않는다
class NoopPushRegistrar implements PushRegistrar {
const NoopPushRegistrar();
@override
Future<void> register() async {}
@override
Future<void> unregister() async {}
}
/// 푸시 기기 등록
final Provider<PushRegistrar> pushRegistrarProvider = Provider<PushRegistrar>(
(Ref ref) => const NoopPushRegistrar(),
);
+51
View File
@@ -0,0 +1,51 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다.
///
/// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림").
/// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다.
class PushTarget {
const PushTarget({required this.module, this.itemId});
/// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null.
static PushTarget? fromData(Map<String, Object?> data) {
final Object? name = data[moduleKey];
final AppModuleId? module = AppModuleId.fromName(
name is String ? name : null,
);
if (module == null) return null;
final Object? id = data[itemIdKey];
return PushTarget(
module: module,
itemId: id is String && id.isNotEmpty ? id : null,
);
}
/// 알림 데이터의 칸 이름 — 서버(푸시 보내는 쪽)와 짝이다
static const String moduleKey = 'module';
static const String itemIdKey = 'id';
final AppModuleId module;
/// 모듈 안의 항목 id (메일 id 등) — 없으면 모듈 첫 화면
final String? itemId;
}
/// 알림을 눌렀을 때 갈 경로 — 꺼졌거나 라우트가 없는 모듈이면 홈(메일 첫 화면).
///
/// [routesOf] 는 모듈 id → 라우트 묶음이다(core/router/module_registry.dart 의 moduleRoutesOf).
String locationForPush(
PushTarget? target,
ModuleRoutes? Function(AppModuleId id) routesOf, {
List<AppModule> modules = kAppModules,
}) {
if (target == null) return homeLocationOf(modules);
final AppModule module = findAppModule(target.module, modules);
final ModuleRoutes? routes = routesOf(target.module);
if (!module.isEnabled || routes == null) return homeLocationOf(modules);
final String? itemId = target.itemId;
final String Function(String itemId)? locationOfItem = routes.locationOfItem;
if (itemId == null || locationOfItem == null) return module.rootPath;
return locationOfItem(itemId);
}
+100
View File
@@ -0,0 +1,100 @@
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/session_redirect.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';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 켜는 모듈 목록 — 시험은 다른 목록으로 덮어쓴다
final Provider<List<AppModule>> appModulesProvider = Provider<List<AppModule>>(
(Ref ref) => kAppModules,
);
/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다.
///
/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다.
/// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다).
/// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다.
/// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException).
///
/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면
/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다).
final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
final List<AppModule> modules = ref.watch(appModulesProvider);
final List<AppModule> enabled = enabledAppModules(modules);
final List<ModuleRoutes> moduleRoutes = enabledModuleRoutes(modules);
final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
final ModuleRouteContext routeContext = ModuleRouteContext(
rootNavigatorKey: rootNavigatorKey,
settingsSections: moduleSettingsSections(moduleRoutes),
);
final _SessionChanges sessionChanges = _SessionChanges();
ref.listen<AsyncValue<SessionState>>(
sessionProvider,
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
sessionChanges.notify(),
);
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.start,
refreshListenable: sessionChanges,
redirect: (BuildContext context, GoRouterState state) =>
resolveSessionRedirect(
ref.read(sessionProvider),
state.uri.path,
modules: modules,
),
// 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로
onException: (BuildContext context, GoRouterState state, GoRouter router) =>
router.go(homeLocationOf(modules)),
routes: <RouteBase>[
_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),
StatefulShellRoute.indexedStack(
builder:
(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) => AppShell(navigationShell: navigationShell, modules: enabled),
branches: <StatefulShellBranch>[
for (final ModuleRoutes module in moduleRoutes)
StatefulShellBranch(routes: module.buildRoutes(routeContext)),
],
),
],
);
ref.onDispose(() {
router.dispose();
sessionChanges.dispose();
});
return router;
});
GoRoute _placeholderRoute(String path, String title) {
return GoRoute(
path: path,
builder: (BuildContext context, GoRouterState state) =>
PlaceholderPage(title: title),
);
}
/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable)
class _SessionChanges extends ChangeNotifier {
void notify() => notifyListeners();
}
+23
View File
@@ -0,0 +1,23 @@
/// 로그인 전 · 제한 단계 화면의 경로 — 탭 막대 밖(셸 밖)에 둔다.
///
/// 로그인 뒤 화면은 모듈 이름부터 시작한다(`/mail/...` · `/settings/...` — core/modules/app_modules.dart,
/// 경로 상수는 모듈마다 `features/<모듈>/<모듈>_routes.dart`). redirect 가 이 구분으로 화면을 고른다(session_redirect.dart).
abstract final class AppRoutes {
/// 세션을 되살리는 동안 · 되살리기가 장애로 실패했을 때
static const String start = '/start';
/// 로그인 — 비밀번호 단계
static const String login = '/login';
/// 로그인 — 2단계 인증 코드 단계
static const String loginCode = '/login/code';
/// 제한된 세션 — 새 비밀번호 정하기
static const String passwordChange = '/login/password';
/// 제한된 세션 — 2단계 인증 켜기
static const String twoFactorSetup = '/login/two-factor';
/// 제한된 세션 — 앱이 모르는 제한
static const String restricted = '/login/restricted';
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다.
///
/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack).
/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다.
/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다.
class AppShell extends StatelessWidget {
const AppShell({
required this.navigationShell,
required this.modules,
super.key,
});
final StatefulNavigationShell navigationShell;
/// 켜진 모듈 — 탭 하나씩 (브랜치 차례와 같다)
final List<AppModule> modules;
/// NavigationBar 는 탭이 둘 이상이어야 한다
static const int _minTabs = 2;
@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: <Widget>[
for (final AppModule module in modules)
NavigationDestination(
icon: Icon(module.icon),
selectedIcon: Icon(module.selectedIcon),
label: module.label,
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/settings/settings_routes.dart';
/// 모듈 id → 그 모듈의 라우트 묶음. 아직 만들지 않은 모듈은 null.
///
/// 모듈을 켜면(app_modules.dart 의 `isEnabled`) 여기에 묶음을 적는다 — 켜진 모듈에 묶음이 없으면
/// 라우터를 만들 때 멈춘다([enabledModuleRoutes]). enum 의 모든 값을 다뤄야 해서 새 모듈 id 도 빠뜨릴 수 없다.
ModuleRoutes? moduleRoutesOf(AppModuleId id) {
return switch (id) {
AppModuleId.mail => mailModuleRoutes,
AppModuleId.settings => settingsModuleRoutes,
AppModuleId.calendar => null,
AppModuleId.files => null,
AppModuleId.contacts => null,
};
}
/// 켜진 모듈의 라우트 묶음 — 탭 막대 차례 그대로
List<ModuleRoutes> enabledModuleRoutes([
List<AppModule> modules = kAppModules,
]) {
return <ModuleRoutes>[
for (final AppModule module in enabledAppModules(modules))
moduleRoutesOf(module.id) ??
(throw StateError('켜진 모듈 ${module.id.name} 의 라우트 묶음이 없다')),
];
}
/// 모듈들이 내놓은 설정 절 — 설정 모듈이 자기 라우트 아래에 붙인다
List<ModuleSettingsSection> moduleSettingsSections(List<ModuleRoutes> routes) {
return <ModuleSettingsSection>[
for (final ModuleRoutes module in routes) ...module.settingsSections,
];
}
@@ -0,0 +1,58 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 세션 상태로 갈 화면을 고른다 — 라우터의 redirect 하나가 이것만 부른다(웹 `router/index.ts` 가드와 같은 결).
///
/// 돌려주는 값: 옮길 경로, 지금 화면에 그대로 있으면 null.
///
/// | 세션 | 화면 |
/// | --- | --- |
/// | 되살리는 중 · 되살리기 실패 | 시작 |
/// | 로그아웃 | 로그인 |
/// | 코드 단계 | 코드 |
/// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 |
/// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 |
String? resolveSessionRedirect(
AsyncValue<SessionState> session,
String location, {
List<AppModule> modules = kAppModules,
}) {
final SessionState? state = session.value;
if (state == null) return _stayOr(location, AppRoutes.start);
switch (state) {
case SignedOut():
return _stayOr(location, AppRoutes.login);
case CodeRequired():
return _stayOr(location, AppRoutes.loginCode);
case SignedIn(:final AuthSessionInfo info):
return switch (info.restriction) {
SessionRestriction.passwordChange => _stayOr(
location,
AppRoutes.passwordChange,
),
SessionRestriction.twoFactorSetup => _stayOr(
location,
AppRoutes.twoFactorSetup,
),
SessionRestriction.unknown => _stayOr(location, AppRoutes.restricted),
null =>
_isSignedOutArea(location) ||
enabledModuleOfPath(location, modules) == null
? homeLocationOf(modules)
: null,
};
}
}
/// 로그인하지 않은 동안만 머무는 화면(시작 · 로그인 · 제한 단계)인가
bool _isSignedOutArea(String location) =>
location == AppRoutes.start ||
location == AppRoutes.login ||
location.startsWith('${AppRoutes.login}/');
String? _stayOr(String location, String target) =>
location == target ? null : target;
+53
View File
@@ -0,0 +1,53 @@
/// 앱 로그인 토큰 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"의 `tokens` 칸
/// (`{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`, 시각은 ISO 8601 UTC).
///
/// 토큰 값은 로그 · 예외 문장에 남기지 않는다 — [toString] 도 값을 가린다.
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.accessExpiresAt,
required this.refreshToken,
required this.refreshExpiresAt,
this.serverTime,
});
final String accessToken;
final DateTime accessExpiresAt;
final String refreshToken;
final DateTime refreshExpiresAt;
/// 토큰을 실어 온 응답의 `Date` 머리(서버 시각) — 남은 시간을 서버 시계로 센다(기기 시계가 틀려도).
/// 응답에 없으면 null — 그때는 기기 시계와 견준다.
final DateTime? serverTime;
/// 응답의 `tokens` 칸을 읽는다. 없거나(`null` — 코드 단계 · 갱신 유예) 모양이 틀리면 null.
static AuthTokens? tryParse(Object? json, {DateTime? serverTime}) {
if (json is! Map<String, Object?>) return null;
final Object? access = json['accessToken'];
final Object? refresh = json['refreshToken'];
final DateTime? accessExpiresAt = _parseTime(json['accessExpiresAt']);
final DateTime? refreshExpiresAt = _parseTime(json['refreshExpiresAt']);
if (access is! String || access.isEmpty) return null;
if (refresh is! String || refresh.isEmpty) return null;
if (accessExpiresAt == null || refreshExpiresAt == null) return null;
return AuthTokens(
accessToken: access,
accessExpiresAt: accessExpiresAt,
refreshToken: refresh,
refreshExpiresAt: refreshExpiresAt,
serverTime: serverTime?.toUtc(),
);
}
/// 액세스 토큰을 쓸 수 있는 시간 — 서버 시계로 센 "받은 때부터 만료까지" (없으면 [fallbackNow] 와 견준다)
Duration accessLifetime(DateTime fallbackNow) =>
accessExpiresAt.difference(serverTime ?? fallbackNow);
static DateTime? _parseTime(Object? value) {
if (value is! String) return null;
return DateTime.tryParse(value)?.toUtc();
}
@override
String toString() => 'AuthTokens(***)';
}
@@ -0,0 +1,28 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 첫 실행 표시 — 앱을 지우면 함께 지워지는 일반 저장소에 둔다.
///
/// iOS Keychain 은 앱을 지워도 남는다. 다시 깐 앱의 첫 실행에 이 표시가 없으면
/// 보안 저장소에 남은 토큰을 지운다(TokenStore.prepare — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일").
/// Android 는 백업을 꺼서(AndroidManifest) 이 표시가 다른 기기로 복원되지 않는다.
abstract interface class FirstRunMarker {
Future<bool> isMarked();
Future<void> mark();
}
/// `shared_preferences` 구현 — 값은 참 · 거짓 하나뿐이다(토큰 · 개인 정보는 두지 않는다)
class PrefsFirstRunMarker implements FirstRunMarker {
PrefsFirstRunMarker([SharedPreferencesAsync? prefs])
: _prefs = prefs ?? SharedPreferencesAsync();
/// 저장 키
static const String key = 'first_run_done';
final SharedPreferencesAsync _prefs;
@override
Future<bool> isMarked() async => await _prefs.getBool(key) ?? false;
@override
Future<void> mark() => _prefs.setBool(key, true);
}
@@ -0,0 +1,43 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 보안 저장소(Android Keystore · iOS Keychain)의 좁은 창구 — 토큰 보관이 쓰는 것만 둔다.
///
/// 시험에서는 메모리 구현으로 바꿔 끼운다. 토큰은 이것으로만 디스크에 둔다
/// (`SharedPreferences` 에 토큰 저장 금지 — 보안 규칙).
abstract interface class SecureStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
/// 이 앱이 둔 값을 모두 지운다 — 첫 실행에 남은 값 지우기
Future<void> deleteAll();
}
/// `flutter_secure_storage` 구현
class FlutterSecureStore implements SecureStore {
const FlutterSecureStore([
this._storage = const FlutterSecureStorage(
// 기본: RSA OAEP 로 감싼 AES-GCM (Android 6.0+). 생체 인증은 쓰지 않는다
aOptions: AndroidOptions(),
// 기기를 처음 잠금 해제한 뒤에만 읽는다 · 다른 기기로 옮겨지지 않는다(iCloud 키체인 · 백업 제외)
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
]);
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
@override
Future<void> delete(String key) => _storage.delete(key: key);
@override
Future<void> deleteAll() => _storage.deleteAll();
}
+201
View File
@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:arasystem_app/core/storage/auth_tokens.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/utils/clock.dart';
import 'package:flutter/services.dart';
/// 리프레시 토큰 저장 키 — 앱 전체에서 이 상수만 쓴다
const String kRefreshTokenKey = 'refresh_token';
/// 액세스 토큰 만료 이만큼 전부터는 미리 갱신한다
const Duration kAccessTokenRefreshMargin = Duration(seconds: 30);
/// 끝난 세션의 까닭을 기억해 두는 수 — 늦게 돌아온 응답이 어느 세션 것인지 가를 만큼만
const int _kEndedSessionMemory = 16;
/// 로그아웃할 때 서버 세션을 끝내는 데 쓸 토큰 (읽지 못했으면 null)
typedef SignOutTokens = ({String? refreshToken, String? accessToken});
/// 앱 로그인 토큰 보관 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일".
///
/// - 리프레시 토큰은 보안 저장소([SecureStore])에만, 액세스 토큰은 메모리에만 둔다.
/// - 새 리프레시 토큰은 받자마자 저장한다 — 서버는 1회용으로 회전하므로, 옛 토큰이 남으면
/// 다음 실행의 갱신이 "이미 쓴 토큰"으로 세션을 끊는다(회전 직후 앱이 죽는 경우는 알려진 한계).
/// 저장이 실패하면 이번 실행 동안 메모리의 새 토큰으로 이어 가고 다음 저장 · 읽기 때 다시 쓴다 — 그 전에 앱이 죽으면 같은 한계다.
/// - 보안 저장소의 쓰기 · 지우기 · 읽기는 부른 차례대로 하나씩 한다 — 로그인 저장과 로그아웃 지우기가 엇갈려 끝난 세션의 토큰이 남지 않게.
/// - 만료는 기기 시계로 판단하지 않는다 — 받은 응답의 `Date`(서버 시각)로 남은 시간을 재고, 받은 때부터 지난 시간을 센다.
/// 기기 시계가 몇 시간 틀려도 매 요청 갱신하거나 끝난 토큰을 쓰지 않는다(시계를 그사이 바꾸면 401 뒤 갱신이 받쳐 준다).
/// - [generation](토큰 세대)은 토큰이 바뀔 때마다, [sessionGeneration](세션 세대)은 세션이 끝날 때만 오른다 —
/// "다른 요청이 이미 갱신했다"와 "그 요청을 보낸 세션이 끝났다(로그아웃 → 다른 직원 로그인)"를 가른다(core/api/auth_interceptor.dart).
class TokenStore {
TokenStore({
required SecureStore secure,
required FirstRunMarker firstRun,
Clock clock = systemClock,
}) : _secure = secure,
_firstRun = firstRun,
_clock = clock;
final SecureStore _secure;
final FirstRunMarker _firstRun;
final Clock _clock;
String? _accessToken;
Duration? _accessLifetime;
DateTime? _accessReceivedAt;
String? _unsavedRefreshToken;
bool _hasRefreshToken = false;
bool _signedOutByUser = false;
int _generation = 0;
int _sessionGeneration = 0;
final Map<int, bool> _endedByUser = <int, bool>{};
Future<void> _storage = Future<void>.value();
/// 앱을 띄우기 전에 한 번 부른다.
///
/// 첫 실행 표시가 없으면(새로 깔았거나 다시 깔았다) 보안 저장소에 남은 값을 모두 지운다 —
/// iOS Keychain 은 앱을 지워도 남기 때문이다. 저장소를 읽지 못해도 로그아웃 상태로 시작한다.
Future<void> prepare() {
return _serial(() async {
try {
if (!await _firstRun.isMarked()) {
await _secure.deleteAll();
await _firstRun.mark();
_hasRefreshToken = false;
return;
}
_hasRefreshToken = await _secure.read(kRefreshTokenKey) != null;
} on PlatformException {
// 키가 바뀌었거나 저장소가 깨졌다 — 다시 로그인하면 새로 쓴다
_hasRefreshToken = false;
}
});
}
/// 리프레시 토큰을 갖고 있는가 — 로그인한 세션이 있다(서버에서 끝났을 수는 있다)
bool get hasSession => _hasRefreshToken;
/// 사용자가 스스로 로그아웃했다(다음 로그인까지)
bool get signedOutByUser => _signedOutByUser;
/// 메모리의 액세스 토큰 (앱을 새로 띄우면 없다)
String? get accessToken => _accessToken;
/// 액세스 토큰이 있고, 받은 때부터 지난 시간이 쓸 수 있는 시간보다 [kAccessTokenRefreshMargin] 넘게 적은가
bool get hasUsableAccessToken {
final Duration? lifetime = _accessLifetime;
final DateTime? receivedAt = _accessReceivedAt;
if (_accessToken == null || lifetime == null || receivedAt == null) {
return false;
}
final Duration elapsed = _clock().difference(receivedAt);
// 기기 시계를 뒤로 돌렸다 — 지난 시간을 모르니 미리 갱신한다
if (elapsed.isNegative) return false;
return elapsed < lifetime - kAccessTokenRefreshMargin;
}
/// 토큰 세대 — 저장 · 지우기마다 하나씩 오른다
int get generation => _generation;
/// 세션 세대 — 세션이 끝날 때(지우기 · 로그아웃)만 오른다. 새 로그인은 올리지 않는다
int get sessionGeneration => _sessionGeneration;
/// [sessionGeneration] 의 세션이 끝났다면 사용자가 스스로 끝냈는가 — 끝나지 않았거나 모르면 null
bool? endedByUser(int sessionGeneration) => _endedByUser[sessionGeneration];
/// 리프레시 토큰 — 저장하지 못한 새 토큰이 있으면 그것(이번에 다시 써 본다), 아니면 보안 저장소의 것
Future<String?> readRefreshToken() {
return _serial(() async {
final String? unsaved = _unsavedRefreshToken;
if (unsaved != null) {
await _writeUnsaved(unsaved);
return unsaved;
}
return _secure.read(kRefreshTokenKey);
});
}
/// 새 토큰을 둔다 — 메모리는 곧바로 바꾸고, 리프레시 토큰은 보안 저장소에 쓴 뒤에 돌아온다.
///
/// 쓰기가 실패하면 `PlatformException` 을 던지되 메모리의 토큰은 남긴다 — 부른 쪽이 정한다
/// (로그인은 그 세션을 끝내고, 갱신은 이번 실행 동안 메모리로 이어 간다).
Future<void> save(AuthTokens tokens) {
final DateTime now = _clock();
_generation++;
_accessToken = tokens.accessToken;
_accessLifetime = tokens.accessLifetime(now);
_accessReceivedAt = now;
_unsavedRefreshToken = tokens.refreshToken;
_hasRefreshToken = true;
_signedOutByUser = false;
final String refreshToken = tokens.refreshToken;
return _serial(() async {
// 그사이 지웠거나 더 새 토큰을 받았으면 이 토큰은 쓰지 않는다
if (_unsavedRefreshToken != refreshToken) return;
await _secure.write(kRefreshTokenKey, refreshToken);
if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null;
});
}
/// 토큰을 모두 지운다 — 세션 끝 · 로그인 저장 실패. 메모리는 곧바로 비운다(보안 저장소를 지우다 실패해도).
Future<void> clear({bool byUser = false}) {
_endSession(byUser: byUser);
return _serial(() => _secure.delete(kRefreshTokenKey));
}
/// 로그아웃 — 메모리를 곧바로 비우고(이 기기는 그 순간 로그아웃이다) 보안 저장소의 리프레시 토큰을 읽어 지운다.
/// 서버 세션을 끝내는 데 쓸 토큰을 돌려준다. 저장소를 읽거나 지우지 못해도 던지지 않는다.
Future<SignOutTokens> signOut() {
final String? accessToken = _accessToken;
final String? unsaved = _unsavedRefreshToken;
_endSession(byUser: true);
return _serial(() async {
// 로그아웃은 어떤 실패에도 끝나야 한다 — 저장소의 뜻밖의 예외도 삼킨다
String? refreshToken = unsaved;
try {
refreshToken ??= await _secure.read(kRefreshTokenKey);
} on Object {
refreshToken = null;
}
try {
await _secure.delete(kRefreshTokenKey);
} on Object {
// 남은 값은 다음 실행의 갱신이 거절되면 지운다
}
return (refreshToken: refreshToken, accessToken: accessToken);
});
}
void _endSession({required bool byUser}) {
_generation++;
_endedByUser[_sessionGeneration] = byUser;
if (_endedByUser.length > _kEndedSessionMemory) {
_endedByUser.remove(_endedByUser.keys.first);
}
_sessionGeneration++;
_accessToken = null;
_accessLifetime = null;
_accessReceivedAt = null;
_unsavedRefreshToken = null;
_hasRefreshToken = false;
_signedOutByUser = byUser;
}
Future<void> _writeUnsaved(String refreshToken) async {
try {
await _secure.write(kRefreshTokenKey, refreshToken);
if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null;
} on PlatformException {
// 이번에도 쓰지 못했다 — 메모리의 토큰으로 이어 간다
}
}
/// 보안 저장소 일을 부른 차례대로 하나씩 한다 — 앞 일의 실패는 뒤 일에 옮기지 않는다
Future<T> _serial<T>(Future<T> Function() operation) {
final Future<T> result = _storage.then((_) => operation());
_storage = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
}
@@ -0,0 +1,12 @@
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 토큰 보관 — main 이 [TokenStore.prepare] 를 마친 것으로 덮어쓴다
/// (`ProviderScope(overrides: [tokenStoreProvider.overrideWithValue(store)])`).
///
/// 준비(첫 실행 지우기)를 마치기 전에 토큰을 읽지 않게, 덮지 않으면 일부러 실패한다.
final Provider<TokenStore> tokenStoreProvider = Provider<TokenStore>(
(Ref ref) => throw UnimplementedError(
'tokenStoreProvider 는 main 에서 준비한 TokenStore 로 덮어써야 한다',
),
);
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/painting.dart';
/// 색 — 웹 `frontend/src/styles/tokens.css` 의 색을 그대로 옮긴다 (이름도 토큰 이름을 따른다).
///
/// 화면은 이 상수를 직접 쓰지 않고 `Theme.of(context).colorScheme` · [AppExtraColors] 로 쓴다.
/// 웹 토큰을 바꾸면 여기도 함께 바꾼다 — 대응은 `app_theme.dart` 의 `_colorScheme` 한 곳이다.
abstract final class AppColors {
// ── 브랜드 ──
/// `--color-brand-surface` — 바탕(레일 · 로그인 패널 · 성공 알림)
static const Color brandSurface = Color(0xFF0B3640);
/// `--color-brand-surface-active`
static const Color brandSurfaceActive = Color(0xFF155A64);
/// `--color-brand` — 강조 단추 · 링크 · 진행률
static const Color brand = Color(0xFF0E7C86);
/// `--color-brand-strong` — 강조 글자 · 눌린 상태
static const Color brandStrong = Color(0xFF0A5F67);
/// `--color-brand-subtle` — 고른 행 · 지금 메일함 · 아바타
static const Color brandSubtle = Color(0xFFE3F2F3);
/// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕
static const Color brandTint = Color(0xFFF3F8F8);
/// `--color-on-brand`
static const Color onBrand = Color(0xFFFFFFFF);
/// `--color-on-brand-text` — 브랜드 바탕 위 글자
static const Color onBrandText = Color(0xFFE6EFF0);
/// `--color-on-brand-muted`
static const Color onBrandMuted = Color(0xFFA3C2C6);
/// `--color-on-brand-link` — 브랜드 바탕 위 링크 · 알림 안 단추
static const Color onBrandLink = Color(0xFF9ED6DB);
// ── 글자 ──
/// `--color-text`
static const Color text = Color(0xFF171C26);
/// `--color-text-body` — 메일 본문
static const Color textBody = Color(0xFF1F2733);
/// `--color-text-strong` — 입력 이름 · 도구 단추
static const Color textStrong = Color(0xFF344054);
/// `--color-text-secondary`
static const Color textSecondary = Color(0xFF4A5363);
/// `--color-text-muted`
static const Color textMuted = Color(0xFF667085);
/// `--color-icon-muted`
static const Color iconMuted = Color(0xFF98A2B3);
// ── 면 · 선 ──
/// `--color-surface`
static const Color surface = Color(0xFFFFFFFF);
/// `--color-surface-panel` — 메일함 패널
static const Color surfacePanel = Color(0xFFF8F9FB);
/// `--color-surface-muted` — 받는 사람 토큰 · 파일 아이콘
static const Color surfaceMuted = Color(0xFFF2F4F7);
/// `--color-surface-sunken` — 목록 머리 줄
static const Color surfaceSunken = Color(0xFFFBFBFC);
/// `--color-border`
static const Color border = Color(0xFFE6E8EC);
/// `--color-border-subtle`
static const Color borderSubtle = Color(0xFFEEF0F3);
/// `--color-border-strong`
static const Color borderStrong = Color(0xFFD0D5DD);
/// `--color-border-input` — 입력 칸 테두리 (흰 바탕 3:1 이상)
static const Color borderInput = Color(0xFF8A94A3);
/// `--color-skeleton` — 불러오는 중 막대 · 진행률 바탕
static const Color skeleton = Color(0xFFE4E7EC);
// ── 상태 ──
/// `--color-danger`
static const Color danger = Color(0xFFB42318);
/// `--color-danger-strong`
static const Color dangerStrong = Color(0xFF912018);
/// `--color-danger-surface`
static const Color dangerSurface = Color(0xFFFEF3F2);
/// `--color-warning-surface`
static const Color warningSurface = Color(0xFFFFF7E6);
/// `--color-warning-text`
static const Color warningText = Color(0xFF7A5200);
/// `--color-warning-border`
static const Color warningBorder = Color(0xFFF0D9A8);
/// `--color-success-text`
static const Color successText = Color(0xFF067647);
/// `--color-star` — 중요 표시
static const Color star = Color(0xFFB7791F);
/// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …)
static const Color shadow = Color(0xFF10181C);
/// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막
static const Color backdrop = Color(0x520B3640);
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// 앱 글꼴 — Pretendard 1.3.9 정적 OTF 넷(Regular · Medium · SemiBold · Bold — pubspec `fonts`).
///
/// SIL OFL 1.1 이다. 앱과 함께 나눌 때 저작권 표시와 라이선스 글을 함께 둬야 해서
/// (OFL 2조) [registerFontLicenses] 가 Flutter 라이선스 화면(`showLicensePage`)에 올린다.
abstract final class AppFonts {
/// pubspec 의 `family` 와 같아야 한다
static const String family = 'Pretendard';
/// 라이선스 글 — npm `pretendard` 1.3.9 의 `dist/LICENSE.txt` 를 그대로 옮겼다 (pubspec `assets`)
static const String licenseAsset = 'assets/licenses/pretendard-ofl.txt';
/// 라이선스 화면에 보일 이름
static const String licensePackageName = 'Pretendard';
/// 글꼴 라이선스를 라이선스 화면에 올린다 — 앱을 띄우기 전에 한 번 부른다.
///
/// 글은 라이선스 화면을 열 때에만 읽는다(LicenseRegistry 가 늦게 부른다).
static void registerFontLicenses({AssetBundle? bundle}) {
final AssetBundle source = bundle ?? rootBundle;
LicenseRegistry.addLicense(() async* {
final String text = await source.loadString(licenseAsset);
yield LicenseEntryWithLineBreaks(<String>[licensePackageName], text);
});
}
}
+55
View File
@@ -0,0 +1,55 @@
/// 간격 · 모서리 · 크기 — 웹 `tokens.css` 의 `--space-*` · `--radius-*` 를 dp 로 옮긴다 (이름의 숫자 = dp).
///
/// 화면은 숫자를 직접 적지 않고 이 상수를 쓴다. 휴대폰에서 쓰지 않을 값은 옮기지 않았다 —
/// 캔버스에서 새 간격이 정해지면 여기에 더한다.
library;
/// 간격 (웹 `--space-N` 과 같은 이름 · 같은 값)
abstract final class AppSpacing {
static const double s2 = 2;
static const double s4 = 4;
static const double s6 = 6;
static const double s8 = 8;
static const double s10 = 10;
static const double s12 = 12;
static const double s14 = 14;
static const double s16 = 16;
static const double s20 = 20;
static const double s24 = 24;
static const double s28 = 28;
static const double s36 = 36;
static const double s40 = 40;
static const double s48 = 48;
static const double s56 = 56;
}
/// 모서리 반지름 (웹 `--radius-*`)
abstract final class AppRadius {
/// `--radius-xs` — 받는 사람 토큰
static const double xs = 4;
/// `--radius-sm`
static const double sm = 6;
/// `--radius-md` — 단추 · 입력
static const double md = 8;
/// `--radius-lg` — 알림
static const double lg = 10;
/// `--radius-xl` — 대화상자
static const double xl = 12;
}
/// 크기
abstract final class AppSize {
/// 누를 수 있는 곳의 최소 크기 — Material 기준 48dp (웹 `--size-touch` 44px 보다 크다).
/// 웹의 "밀도 예외"는 앱에 옮기지 않는다 — 캔버스에서 따로 정한다.
static const double touchMin = 48;
/// 선 두께 — `--border-width` 1px
static const double borderWidth = 1;
/// 초점 고리 두께 — `--focus-ring-width` 2px
static const double focusRingWidth = 2;
}
+234
View File
@@ -0,0 +1,234 @@
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:flutter/material.dart';
/// 앱 테마 — 밝은 테마 하나(다크 모드는 첫 판 밖).
///
/// 색은 시드 생성(`ColorScheme.fromSeed`)을 쓰지 않고 웹 토큰을 역할마다 손으로 대응한다 —
/// 시드는 브랜드 청록과 다른 색을 만들어 웹 화면과 어긋난다.
abstract final class AppTheme {
static ThemeData light() {
const ColorScheme scheme = _colorScheme;
final TextTheme textTheme = AppTypography.textTheme();
final RoundedRectangleBorder controlShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
);
const Size touchSize = Size(AppSize.touchMin, AppSize.touchMin);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: AppFonts.family,
textTheme: textTheme,
scaffoldBackgroundColor: scheme.surface,
// 누르는 곳은 늘 48dp 이상 — 작은 단추도 누르는 영역을 48 로 넓힌다
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard,
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: AppSize.borderWidth,
space: AppSize.borderWidth,
),
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: scheme.onSurface,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
textStyle: textTheme.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
side: const BorderSide(color: AppColors.borderStrong),
textStyle: textTheme.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
textStyle: textTheme.labelLarge,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(minimumSize: touchSize),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
vertical: AppSpacing.s14,
),
border: _inputBorder(AppColors.borderInput),
enabledBorder: _inputBorder(AppColors.borderInput),
focusedBorder: _inputBorder(
scheme.primary,
width: AppSize.focusRingWidth,
),
errorBorder: _inputBorder(scheme.error),
focusedErrorBorder: _inputBorder(
scheme.error,
width: AppSize.focusRingWidth,
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: scheme.inverseSurface,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.onInverseSurface,
),
actionTextColor: scheme.inversePrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
extensions: const <AppExtraColors>[AppExtraColors.light],
);
}
static OutlineInputBorder _inputBorder(
Color color, {
double width = AppSize.borderWidth,
}) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: color, width: width),
);
}
/// 웹 토큰 → Material 3 색 역할 (대응을 바꾸면 웹 화면과 견주어 본다)
static const ColorScheme _colorScheme = ColorScheme(
brightness: Brightness.light,
// 강조 단추 · 링크 · 진행률
primary: AppColors.brand,
onPrimary: AppColors.onBrand,
// 고른 행 · 지금 메일함 — 그 위 글자는 강조 글자
primaryContainer: AppColors.brandSubtle,
onPrimaryContainer: AppColors.brandStrong,
// 브랜드 바탕(로그인 패널 · 성공 알림)
secondary: AppColors.brandSurface,
onSecondary: AppColors.onBrandText,
secondaryContainer: AppColors.brandTint,
onSecondaryContainer: AppColors.text,
tertiary: AppColors.brandSurfaceActive,
onTertiary: AppColors.onBrand,
error: AppColors.danger,
onError: AppColors.onBrand,
errorContainer: AppColors.dangerSurface,
onErrorContainer: AppColors.dangerStrong,
surface: AppColors.surface,
onSurface: AppColors.text,
onSurfaceVariant: AppColors.textSecondary,
surfaceContainerLowest: AppColors.surface,
surfaceContainerLow: AppColors.surfaceSunken,
surfaceContainer: AppColors.surfacePanel,
surfaceContainerHigh: AppColors.surfaceMuted,
surfaceContainerHighest: AppColors.skeleton,
// 입력 칸 테두리(3:1) · 나눔선
outline: AppColors.borderInput,
outlineVariant: AppColors.border,
shadow: AppColors.shadow,
scrim: AppColors.backdrop,
// 스낵바(알림) — 웹 알림처럼 브랜드 바탕에 밝은 글자
inverseSurface: AppColors.brandSurface,
onInverseSurface: AppColors.onBrandText,
inversePrimary: AppColors.onBrandLink,
// 높이에 따라 면에 색을 섞지 않는다 — 웹처럼 흰 면 그대로
surfaceTint: AppColors.surface,
);
}
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
@immutable
class AppExtraColors extends ThemeExtension<AppExtraColors> {
const AppExtraColors({
required this.textMuted,
required this.textBody,
required this.iconMuted,
required this.warningSurface,
required this.warningText,
required this.warningBorder,
required this.successText,
required this.star,
});
static const AppExtraColors light = AppExtraColors(
textMuted: AppColors.textMuted,
textBody: AppColors.textBody,
iconMuted: AppColors.iconMuted,
warningSurface: AppColors.warningSurface,
warningText: AppColors.warningText,
warningBorder: AppColors.warningBorder,
successText: AppColors.successText,
star: AppColors.star,
);
/// 시각 · 개수 · 보조 설명
final Color textMuted;
/// 메일 본문
final Color textBody;
final Color iconMuted;
/// 노란 안내 줄
final Color warningSurface;
final Color warningText;
final Color warningBorder;
final Color successText;
/// 중요 표시
final Color star;
@override
AppExtraColors copyWith({
Color? textMuted,
Color? textBody,
Color? iconMuted,
Color? warningSurface,
Color? warningText,
Color? warningBorder,
Color? successText,
Color? star,
}) {
return AppExtraColors(
textMuted: textMuted ?? this.textMuted,
textBody: textBody ?? this.textBody,
iconMuted: iconMuted ?? this.iconMuted,
warningSurface: warningSurface ?? this.warningSurface,
warningText: warningText ?? this.warningText,
warningBorder: warningBorder ?? this.warningBorder,
successText: successText ?? this.successText,
star: star ?? this.star,
);
}
@override
AppExtraColors lerp(ThemeExtension<AppExtraColors>? other, double t) {
if (other is! AppExtraColors) return this;
return AppExtraColors(
textMuted: Color.lerp(textMuted, other.textMuted, t)!,
textBody: Color.lerp(textBody, other.textBody, t)!,
iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!,
warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!,
warningText: Color.lerp(warningText, other.warningText, t)!,
warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!,
successText: Color.lerp(successText, other.successText, t)!,
star: Color.lerp(star, other.star, t)!,
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:flutter/material.dart';
/// 글자 크기 · 굵기 · 줄 높이 — 바꿀 때는 이 파일 한 곳만 고친다.
///
/// 정한 값(`docs/design/app/README.md` "크기 · 글꼴" — 2026-11-05 확정): 화면 글 15 · 입력 칸 16(`bodyLarge` — TextField 의 기본)
/// · 메일 본문 15 · 줄 간격 1.75([lineHeightBody] — 본문은 WebView 가 그린다) · 위 막대 제목 18(`titleMedium`).
/// 그 밖의 역할(제목 · 꼬리표)은 **임시 값**이다 — 화면 작업 때 보드와 견주어 정한다.
/// 화면은 숫자를 적지 않고 `Theme.of(context).textTheme` 의 역할 이름으로만 쓴다.
abstract final class AppTypography {
// ── 크기 (sp — 기기 글자 크기 설정을 따라 커진다) ──
static const double display = 28;
static const double headline = 22;
static const double titleLarge = 20;
/// 위 막대 제목 (확정)
static const double titleMedium = 18;
static const double titleSmall = 15;
/// 입력 칸 (확정)
static const double bodyLarge = 16;
/// 화면 글 · 메일 본문 (확정)
static const double bodyMedium = 15;
static const double bodySmall = 13;
static const double labelLarge = 15;
static const double labelMedium = 13;
static const double labelSmall = 12;
// ── 줄 높이 (웹 `--line-height-*`) ──
static const double lineHeightBase = 1.5;
static const double lineHeightHeading = 1.4;
static const double lineHeightTight = 1.3;
/// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정)
static const double lineHeightBody = 1.75;
// ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ──
static const double letterSpacingTightEm = -0.01;
static const double letterSpacingHeadingEm = -0.02;
// ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ──
static const FontWeight regular = FontWeight.w400;
static const FontWeight medium = FontWeight.w500;
static const FontWeight semiBold = FontWeight.w600;
static const FontWeight bold = FontWeight.w700;
/// 앱의 글자 체계 — 색은 테마가 입힌다 (`ThemeData.textTheme` 에 색을 넣지 않는다)
static TextTheme textTheme() {
return const TextTheme(
displaySmall: TextStyle(
fontSize: display,
fontWeight: bold,
height: lineHeightTight,
letterSpacing: display * letterSpacingHeadingEm,
),
headlineSmall: TextStyle(
fontSize: headline,
fontWeight: bold,
height: lineHeightHeading,
letterSpacing: headline * letterSpacingHeadingEm,
),
titleLarge: TextStyle(
fontSize: titleLarge,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleLarge * letterSpacingHeadingEm,
),
titleMedium: TextStyle(
fontSize: titleMedium,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleMedium * letterSpacingTightEm,
),
titleSmall: TextStyle(
fontSize: titleSmall,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleSmall * letterSpacingTightEm,
),
bodyLarge: TextStyle(
fontSize: bodyLarge,
fontWeight: regular,
height: lineHeightBase,
),
bodyMedium: TextStyle(
fontSize: bodyMedium,
fontWeight: regular,
height: lineHeightBase,
),
bodySmall: TextStyle(
fontSize: bodySmall,
fontWeight: regular,
height: lineHeightBase,
),
labelLarge: TextStyle(
fontSize: labelLarge,
fontWeight: semiBold,
height: lineHeightTight,
),
labelMedium: TextStyle(
fontSize: labelMedium,
fontWeight: medium,
height: lineHeightTight,
),
labelSmall: TextStyle(
fontSize: labelSmall,
fontWeight: medium,
height: lineHeightTight,
),
).apply(fontFamily: AppFonts.family);
}
}
-1
View File
@@ -1 +0,0 @@
// Empty directory placeholder
+7
View File
@@ -0,0 +1,7 @@
/// 지금 시각을 주는 함수 — 시간에 기대는 로직(토큰 만료 · 알림 겹침 · 코드 단계 10분)은 이것을 주입받는다.
///
/// 시험은 손으로 돌리는 시계를 넣어 고정 대기 없이 시간을 넘긴다 (테스트 규칙 "결정적").
typedef Clock = DateTime Function();
/// 기본 시계
DateTime systemClock() => DateTime.now();
@@ -0,0 +1,6 @@
/// 프로바이더의 실패를 저절로 다시 하지 않는다 — 늘 null(다시 하지 않음)을 돌려준다.
///
/// Riverpod 3 은 `retry` 를 주지 않으면(null) 기본으로 다시 한다(실패한 프로바이더를 10번까지 다시 만든다 —
/// 그때마다 요청을 다시 보낸다). 앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 이것을 걸어 끄고,
/// 다시 하기는 화면의 [다시 시도]가 한다.
Duration? noAutomaticRetry(int retryCount, Object error) => null;
@@ -0,0 +1,53 @@
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
/// 로그인 단계의 결과 (계약 5절 "로그인" — `status`)
enum LoginStatus {
/// 로그인했다 — 토큰이 실려 온다
signedIn,
/// 비밀번호는 맞고 2단계 인증 코드가 필요하다 — 토큰 없음, `email` · `name` 은 null
twoFactorRequired,
}
/// `POST /api/auth/app/login` 의 응답 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"
/// (웹 login 과 같은 평평한 칸 `status` · `email` · `name` · `restriction` · `twoFactorDeadline` + `tokens`)
class AppLoginResult {
const AppLoginResult({required this.status, this.session, this.tokens});
final LoginStatus status;
/// `signedIn` 일 때 직원 · 세션 칸
final AuthSessionInfo? session;
/// `signedIn` 일 때만 — 코드 단계는 null
final AuthTokens? tokens;
/// 응답을 읽는다. `signedIn` 인데 세션 칸 · 토큰이 없으면 서버와 계약이 어긋난 것이라 [FormatException].
/// [serverTime] 은 응답의 `Date` 머리 — 토큰의 남은 시간을 서버 시계로 잰다.
factory AppLoginResult.fromJson(
Map<String, Object?> json, {
DateTime? serverTime,
}) {
final Object? status = json['status'];
if (status == LoginStatus.twoFactorRequired.name) {
return const AppLoginResult(status: LoginStatus.twoFactorRequired);
}
if (status != LoginStatus.signedIn.name) {
throw const FormatException('로그인 응답의 status 를 알 수 없다');
}
final AuthSessionInfo? session = AuthSessionInfo.tryParse(json);
final AuthTokens? tokens = AuthTokens.tryParse(
json['tokens'],
serverTime: serverTime,
);
if (session == null || tokens == null) {
throw const FormatException('signedIn 응답에 직원 · 토큰 칸이 없다');
}
return AppLoginResult(
status: LoginStatus.signedIn,
session: session,
tokens: tokens,
);
}
}
@@ -0,0 +1,69 @@
/// 제한된 세션 (계약 6절) — 로그인은 됐지만 그 단계를 마칠 때까지 다른 API 는 403 `AUTH_010` 이다
enum SessionRestriction {
/// 새 비밀번호를 정할 때까지
passwordChange,
/// 2단계 인증을 켤 때까지
twoFactorSetup,
/// 앱이 모르는 제한 — 웹처럼 갱신 · 다시 보내기 없이 사전 문구와 [다른 계정으로 로그인]만 보인다
unknown;
/// 응답 칸을 읽는다 — 없거나 빈 값이면 제한 없음(null), 아는 이름이면 그 이름, 그 밖은 [unknown]
static SessionRestriction? parse(Object? value) {
if (value == null || value == '') return null;
for (final SessionRestriction restriction in SessionRestriction.values) {
if (restriction != unknown && restriction.name == value) {
return restriction;
}
}
return unknown;
}
}
/// 로그인한 직원과 세션 칸 — 로그인(`signedIn`) · me · 갱신 응답이 함께 싣는다(계약 1 · 6절)
class AuthSessionInfo {
const AuthSessionInfo({
required this.email,
required this.name,
this.restriction,
this.twoFactorDeadline,
});
final String email;
final String name;
/// 제한 — null 이면 제한 없음
final SessionRestriction? restriction;
/// 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각(UTC) — 안내 줄은 그 1ms 앞 날짜까지
final DateTime? twoFactorDeadline;
/// 응답의 세션 칸을 읽는다 — `email` · `name` 이 없으면(코드 단계 · 토큰만 실린 갱신 응답) null
static AuthSessionInfo? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? email = json['email'];
final Object? name = json['name'];
if (email is! String || email.isEmpty || name is! String) return null;
final Object? deadline = json['twoFactorDeadline'];
return AuthSessionInfo(
email: email,
name: name,
restriction: SessionRestriction.parse(json['restriction']),
twoFactorDeadline: deadline is String
? DateTime.tryParse(deadline)?.toUtc()
: null,
);
}
@override
bool operator ==(Object other) =>
other is AuthSessionInfo &&
other.email == email &&
other.name == name &&
other.restriction == restriction &&
other.twoFactorDeadline == twoFactorDeadline;
@override
int get hashCode => Object.hash(email, name, restriction, twoFactorDeadline);
}
@@ -0,0 +1,107 @@
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/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';
/// 로그인 화면이 칸 아래 · 위쪽 상자에 직접 보이는 실패 — 알림을 띄우지 않는다
/// (계약 5절 "클라이언트": `AUTH_004` 위쪽 상자, `AUTH_006` · `AUTH_007` 코드 칸 아래, `VAL_001` 칸 검사)
const Set<String> kLoginInlineCodes = <String>{
'AUTH_004',
'AUTH_006',
'AUTH_007',
'VAL_001',
};
/// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다
const String kMalformedResponseCode = 'SYS_001';
/// 앱 로그인 API — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"(서버는 아직 없다 — 시험은 가짜 서버로 한다).
///
/// 응답 언래핑과 에러 코드는 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [kMalformedResponseCode].
/// 비밀번호 · 코드 · 토큰은 로그 · 예외에 싣지 않는다 — 비밀이 든 요청(`ApiOptions.secret`)의 실패 예외는 본문과 Bearer 를 비운 채 온다.
class AuthRepository {
const AuthRepository(this._dio, this._refresher);
final Dio _dio;
final TokenRefresher _refresher;
/// 로그인 — 2단계 인증을 켠 직원은 코드 단계에서 [otpCode] 와 비밀번호를 한 번 더 보낸다(계약 5절)
Future<AppLoginResult> login({
required String email,
required String password,
String? otpCode,
}) async {
final Response<Object?> response = await _dio.post<Object?>(
ApiPaths.appLogin,
data: <String, Object?>{
'email': email,
'password': password,
'otpCode': ?otpCode,
},
// 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다)
options: ApiOptions.secret(silent: kLoginInlineCodes),
);
final Object? body = response.data;
if (body is Map<String, Object?>) {
try {
return AppLoginResult.fromJson(
body,
serverTime: serverTimeOf(response),
);
} on FormatException {
// 아래에서 같은 실패로 던진다
}
}
throw _malformed(response);
}
/// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기)
Future<AuthSessionInfo> me({bool silent = false}) async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.me,
options: silent ? ApiOptions.silent() : null,
);
final AuthSessionInfo? info = AuthSessionInfo.tryParse(response.data);
if (info == null) throw _malformed(response);
return info;
}
/// 지금 세션을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때만 쓴다
/// (인터셉터가 그 결과를 기다리는 중이라 같은 줄로 보내면 서로 기다린다). 알림은 띄우지 않는다.
Future<AuthSessionInfo> readSessionDirect() async {
final Map<String, Object?> body = await _refresher.readSession();
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
if (info == null) {
throw apiFailure(
RequestOptions(path: ApiPaths.me),
kMalformedResponseCode,
);
}
return info;
}
/// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나).
/// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다.
Future<RefreshOutcome> refresh() => _refresher.refresh();
/// 서버 세션을 끝낸다 — 리프레시 토큰을 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다.
/// 서버는 어느 쪽도 맞지 않아도 성공으로 답한다. 알림은 띄우지 않는다.
Future<void> logout({
required String? refreshToken,
required String? accessToken,
}) =>
postAppLogout(_dio, refreshToken: refreshToken, accessToken: accessToken);
DioException _malformed(Response<Object?> response) => withoutSecrets(
apiFailure(
response.requestOptions,
kMalformedResponseCode,
response: response,
),
);
}
@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:arasystem_app/core/utils/clock.dart';
/// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계)
const Duration kLoginSecretLifetime = Duration(minutes: 10);
/// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다.
///
/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다).
/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분.
/// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다.
class LoginSecret {
LoginSecret({
Clock clock = systemClock,
Duration lifetime = kLoginSecretLifetime,
}) : _clock = clock,
_lifetime = lifetime;
final Clock _clock;
final Duration _lifetime;
String? _email;
String? _password;
DateTime? _confirmedAt;
Timer? _timer;
/// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다
void hold(String email, String password) {
clear();
_email = email;
_password = password;
renew();
}
/// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다
void renew() {
if (_password == null) return;
_confirmedAt = _clock();
_timer?.cancel();
_timer = Timer(_lifetime, clear);
}
/// 쥐고 있는 주소 · 비밀번호 — 없거나 10분이 지났으면 null(지난 것은 지운다)
({String email, String password})? read() {
final String? email = _email;
final String? password = _password;
final DateTime? confirmedAt = _confirmedAt;
if (email == null || password == null || confirmedAt == null) return null;
if (_clock().difference(confirmedAt) >= _lifetime) {
clear();
return null;
}
return (email: email, password: password);
}
/// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다)
bool get isHeld => _password != null;
void clear() {
_timer?.cancel();
_timer = null;
_email = null;
_password = null;
_confirmedAt = null;
}
}
@@ -0,0 +1,381 @@
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/dio_provider.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/storage/auth_tokens.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/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/presentation/providers/login_secret.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';
/// 인증 저장소
final Provider<AuthRepository> authRepositoryProvider =
Provider<AuthRepository>(
(Ref ref) => AuthRepository(
ref.watch(dioProvider),
ref.watch(tokenRefresherProvider),
),
);
/// 세션 — 앱에 하나. 라우터 redirect 가 이것을 보고 화면을 고른다.
///
/// 되살리기가 장애로 실패해도 저절로 다시 하지 않는다(`noAutomaticRetry` — Riverpod 3 은 `retry` 를 주지 않으면 기본으로 다시 한다).
/// 시작 화면이 [SessionNotifier.retry] 를 부른다. 앱 전체도 ProviderScope 의 `retry` 로 끈다(main.dart).
final AsyncNotifierProvider<SessionNotifier, SessionState> sessionProvider =
AsyncNotifierProvider<SessionNotifier, SessionState>(
SessionNotifier.new,
retry: noAutomaticRetry,
);
/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기.
///
/// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다.
/// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다.
/// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다.
/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다.
/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의
/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게).
class SessionNotifier extends AsyncNotifier<SessionState> {
LoginSecret? _secretHolder;
Future<RestrictionCheck>? _rechecking;
int _epoch = 0;
LoginSecret get _secret =>
_secretHolder ??= LoginSecret(clock: ref.read(clockProvider));
AuthRepository get _repository => ref.read(authRepositoryProvider);
TokenStore get _store => ref.read(tokenStoreProvider);
TokenRefresher get _refresher => ref.read(tokenRefresherProvider);
PushRegistrar get _push => ref.read(pushRegistrarProvider);
@override
Future<SessionState> build() async {
final StreamSubscription<SessionEvent> subscription = ref
.watch(sessionEventsProvider)
.stream
.listen(_onEvent);
ref.onDispose(() {
unawaited(subscription.cancel());
_secretHolder?.clear();
});
final TokenStore store = ref.watch(tokenStoreProvider);
if (!store.hasSession) return const SignedOut();
return _restore();
}
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
({String email, String password})? get heldLogin => _secret.read();
/// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다
void retry() {
_refresher.forgetFailure();
ref.invalidateSelf();
}
/// 비밀번호 단계
Future<void> signIn({required String email, required String password}) async {
final int epoch = _epoch;
final AppLoginResult result = await _repository.login(
email: email,
password: password,
);
await _apply(result, epoch: epoch, email: email, password: password);
}
/// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절).
///
/// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다.
Future<void> submitCode(String otpCode) async {
final ({String email, String password})? held = _secret.read();
if (held == null) {
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.codeTimedOut),
);
return;
}
final int epoch = _epoch;
final AppLoginResult result;
try {
result = await _repository.login(
email: held.email,
password: held.password,
otpCode: otpCode,
);
} on DioException catch (error) {
// 그사이 [다른 계정으로 로그인] · 로그아웃했으면 지금 화면을 건드리지 않는다
if (epoch == _epoch &&
ref.mounted &&
apiErrorCodeOf(error) == 'AUTH_004') {
_secret.clear();
state = const AsyncData<SessionState>(SignedOut());
}
rethrow;
}
await _apply(
result,
epoch: epoch,
email: held.email,
password: held.password,
);
}
/// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고,
/// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다.
Future<void> switchAccount() async {
if (_store.hasSession || state.value is SignedIn) {
await signOut(reason: SignOutReason.switchAccount);
return;
}
_epoch++;
_secret.clear();
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.switchAccount),
);
}
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다.
/// 서버에 닿지 못해도 이 기기는 로그아웃 상태다.
Future<void> signOut({SignOutReason reason = SignOutReason.none}) async {
_epoch++;
_secret.clear();
// 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다)
final Future<SignOutTokens> clearing = _store.signOut();
state = AsyncData<SessionState>(SignedOut(reason: reason));
final SignOutTokens tokens = await clearing;
unawaited(_unregisterPushQuietly());
if (tokens.refreshToken == null && tokens.accessToken == null) return;
try {
await _repository.logout(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
);
} on DioException {
// 서버 세션은 쓰지 않으면 미사용 14일에 끝난다
}
}
/// 제한(`AUTH_010`)을 다시 맞춘다 — 웹 `recheckRestriction` 과 같은 결. 겹친 요청은 하나로 묶는다.
///
/// 세션을 인증 인터셉터 줄 밖에서 묻는다(인터셉터가 이 결과를 기다리는 중이라).
/// - 제한이 있으면 그 단계로 옮긴다 → [RestrictionCheck.restricted]
/// - 제한이 없다는데 `AUTH_010` 이면 옛 토큰이다 — 갱신해 새 토큰을 받는다 → [RestrictionCheck.renewed]
/// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale]
/// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown]
Future<RestrictionCheck> recheck() {
return _rechecking ??= _recheck().whenComplete(() => _rechecking = null);
}
Future<RestrictionCheck> _recheck() async {
final int epoch = _epoch;
if (state.value is! SignedIn) return RestrictionCheck.stale;
bool isCurrent() =>
epoch == _epoch && ref.mounted && state.value is SignedIn;
AuthSessionInfo? info;
try {
info = await _repository.readSessionDirect();
} on DioException catch (error) {
if (!isCurrent()) return RestrictionCheck.stale;
// 액세스 토큰이 끝났으면 갱신해 본다(아래) — 그 밖의 장애는 확인하지 못한 것이다
if (apiErrorCodeOf(error) != 'AUTH_001') return RestrictionCheck.unknown;
}
if (!isCurrent()) return RestrictionCheck.stale;
if (info != null && info.restriction != null) {
_setSignedIn(info);
return RestrictionCheck.restricted;
}
final RefreshOutcome outcome = await _repository.refresh();
if (!isCurrent()) return RestrictionCheck.stale;
switch (outcome) {
case RefreshSucceeded(:final Map<String, Object?>? body):
final AuthSessionInfo? refreshed =
AuthSessionInfo.tryParse(body) ?? info;
if (refreshed == null) return RestrictionCheck.renewed;
_setSignedIn(refreshed);
return refreshed.restriction == null
? RestrictionCheck.renewed
: RestrictionCheck.restricted;
case RefreshRejected():
case RefreshDiscarded():
// 세션 끝 흐름이 로그인 화면으로 옮긴다
return RestrictionCheck.stale;
case RefreshFailed():
return RestrictionCheck.unknown;
}
}
Future<SessionState> _restore() async {
final int epoch = _epoch;
final AuthSessionInfo info;
try {
info = await _repository.me(silent: true);
} on DioException catch (error) {
// 되살리는 사이 [다른 계정으로 로그인] · 로그아웃했다 — 그 상태를 그대로 둔다
if (epoch != _epoch) return _currentSignedOut();
final String? code = apiErrorCodeOf(error);
if (code == 'AUTH_001' || code == 'AUTH_002') {
await _clearQuietly();
return const SignedOut();
}
// 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다
rethrow;
}
if (epoch != _epoch) return _currentSignedOut();
_afterSignedIn(info, wasReady: false);
return SignedIn(info);
}
/// 되살리는 사이 바뀐 로그아웃 상태 — build 의 결과가 그 상태를 덮어쓰지 않게 같은 값을 돌려준다
SessionState _currentSignedOut() {
final SessionState? current = state.value;
return current is SignedOut ? current : const SignedOut();
}
Future<void> _apply(
AppLoginResult result, {
required int epoch,
required String email,
required String password,
}) async {
if (!ref.mounted || epoch != _epoch) {
// 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다
final AuthTokens? tokens = result.tokens;
if (tokens != null) unawaited(_logoutQuietly(tokens));
return;
}
switch (result.status) {
case LoginStatus.twoFactorRequired:
_secret.hold(email, password);
state = AsyncData<SessionState>(CodeRequired(email));
case LoginStatus.signedIn:
final AuthTokens tokens = result.tokens!;
// 저장은 메모리를 곧바로 바꾸고 세대를 올린다 — 이 저장의 세대는 기다리기 전에 읽는다
final Future<void> saving = _store.save(tokens);
final int savedGeneration = _store.generation;
try {
await saving;
} on PlatformException {
// 받은 토큰을 둘 곳이 없다 — 이 기기의 토큰을 지우고 그 세션도 끝낸 뒤 실패로 알린다
if (_store.generation == savedGeneration) await _clearQuietly();
unawaited(_logoutQuietly(tokens));
throw apiFailure(
RequestOptions(path: ApiPaths.appLogin),
kMalformedResponseCode,
);
}
if (!ref.mounted || epoch != _epoch) {
// 저장하는 사이 로그아웃했다 — 그 뒤에 다른 일이 없었으면 방금 둔 토큰을 지우고, 그 세션은 끝낸다
if (_store.generation == savedGeneration) await _clearQuietly();
unawaited(_logoutQuietly(tokens));
return;
}
final AuthSessionInfo info = result.session!;
if (info.restriction != null) {
// 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다
_secret.hold(email, password);
}
_setSignedIn(info);
}
}
/// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다
void _setSignedIn(AuthSessionInfo info) {
final SessionState? previous = state.value;
final bool wasReady =
previous is SignedIn && previous.info.restriction == null;
state = AsyncData<SessionState>(SignedIn(info));
_afterSignedIn(info, wasReady: wasReady);
}
void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) {
if (info.restriction != null) return;
// 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일")
_secret.clear();
if (!wasReady) unawaited(_registerPush());
}
Future<void> _registerPush() async {
try {
await _push.register();
} on Object {
// 알림 등록이 안 돼도 로그인은 그대로다 — 다음 로그인 · 되살리기 때 다시 한다
}
}
Future<void> _unregisterPushQuietly() async {
try {
await _push.unregister().timeout(kPushUnregisterTimeout);
} on Object {
// 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다
}
}
void _onEvent(SessionEvent event) {
final SessionState? current = state.value;
switch (event) {
case SessionRecheckNeeded():
if (current is! SignedIn) {
event.reply(RestrictionCheck.stale);
return;
}
unawaited(
recheck().then<void>(
event.reply,
onError: (Object _, StackTrace _) =>
event.reply(RestrictionCheck.unknown),
),
);
case SessionEnded():
if (current is! SignedIn) return;
_epoch++;
_secret.clear();
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.expired),
);
case SessionRefreshed(:final Map<String, Object?> body):
// 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다)
if (current is! SignedIn) return;
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
if (info != null &&
info.email == current.info.email &&
info != current.info) {
_setSignedIn(info);
}
}
}
Future<void> _logoutQuietly(AuthTokens tokens) async {
try {
await _repository.logout(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
);
} on DioException {
// 닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다
}
}
Future<void> _clearQuietly() async {
try {
await _store.clear();
} on PlatformException {
// 메모리의 토큰은 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 지운다
}
}
}
@@ -0,0 +1,65 @@
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
/// 세션 상태 — 라우터의 redirect 가 이것으로 화면을 고른다(core/router/session_redirect.dart).
///
/// "되살리는 중"은 상태가 아니라 세션 노티파이어의 `AsyncLoading` 이고, 되살리기가 장애로 실패하면 `AsyncError` 다.
sealed class SessionState {
const SessionState();
}
/// 로그아웃 상태 — 로그인 화면
final class SignedOut extends SessionState {
const SignedOut({this.reason = SignOutReason.none});
/// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다)
final SignOutReason reason;
@override
bool operator ==(Object other) =>
other is SignedOut && other.reason == reason;
@override
int get hashCode => reason.hashCode;
}
/// 로그아웃한 까닭
enum SignOutReason {
/// 처음 · 스스로 로그아웃
none,
/// 세션이 끝났다(`AUTH_002` — 갱신 거절 · 다른 곳에서 끊음)
expired,
/// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.")
codeTimedOut,
/// [다른 계정으로 로그인]
switchAccount,
}
/// 비밀번호는 맞고 2단계 인증 코드를 기다린다 — 비밀번호는 상태에 두지 않는다(노티파이어 메모리에만 10분)
final class CodeRequired extends SessionState {
const CodeRequired(this.email);
final String email;
@override
bool operator ==(Object other) =>
other is CodeRequired && other.email == email;
@override
int get hashCode => email.hashCode;
}
/// 로그인했다 — 제한이 있으면 그 단계 화면, 없으면 메일
final class SignedIn extends SessionState {
const SignedIn(this.info);
final AuthSessionInfo info;
@override
bool operator ==(Object other) => other is SignedIn && other.info == info;
@override
int get hashCode => info.hashCode;
}
@@ -0,0 +1,66 @@
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/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
/// 메일 모듈의 경로 — 모두 `/mail` 아래다
abstract final class MailRoutes {
/// 첫 화면 — 받은편지함 목록 (탭 안)
static final String home = findAppModule(AppModuleId.mail).rootPath;
/// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다)
static String mailbox(String mailboxId) =>
'$home/mailboxes/${Uri.encodeComponent(mailboxId)}';
/// 메일 읽기 (전체 화면 — 탭 막대 없이)
static String message(String messageId) =>
'$home/messages/${Uri.encodeComponent(messageId)}';
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
static String get compose => '$home/compose';
}
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
id: AppModuleId.mail,
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
locationOfItem: MailRoutes.message,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: MailRoutes.home,
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
title: MailTexts.home,
links: <PlaceholderLink>[
PlaceholderLink(
label: MailTexts.compose,
location: MailRoutes.compose,
),
],
),
routes: <RouteBase>[
GoRoute(
path: 'mailboxes/:mailboxId',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.mailbox),
),
GoRoute(
path: 'messages/:messageId',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.message),
),
GoRoute(
path: 'compose',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.compose),
),
],
),
],
);
@@ -0,0 +1,16 @@
/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준).
abstract final class MailTexts {
/// 메일 첫 화면 — 받은편지함 목록
static const String home = '메일';
/// 다른 메일함의 목록 (탭 안)
static const String mailbox = '메일함';
/// 메일 읽기 (전체 화면)
static const String message = '메일 읽기';
/// 메일 쓰기 (전체 화면)
static const String compose = '메일 쓰기';
}
@@ -0,0 +1,25 @@
/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤).
abstract final class SettingsTexts {
/// 설정 첫 화면
static const String home = '설정';
/// 내 정보 — 이름 · 주소
static const String account = '내 정보';
/// 알림 켜기 · 끄기
static const String notifications = '알림';
/// 앱 정보 — 판 · 오픈소스 라이선스
static const String about = '앱 정보';
/// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry)
static const String licenses = '오픈소스 라이선스';
/// 문의 연락처 (`GET /api/support-contact`)
static const String support = '문의 연락처';
/// 로그아웃 (화면이 아니라 하는 일)
static const String signOut = '로그아웃';
}
@@ -0,0 +1,144 @@
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: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';
/// 다른 모듈이 내놓은 설정 절 (`/settings/<path>`)
static String section(String path) => '$home/$path';
}
/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃.
/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다.
final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
id: AppModuleId.settings,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: SettingsRoutes.home,
builder: (BuildContext _, GoRouterState _) =>
_SettingsHomePlaceholder(sections: context.settingsSections),
routes: <RouteBase>[
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>[
PlaceholderLink(
label: SettingsTexts.licenses,
location: SettingsRoutes.licenses,
),
],
),
routes: <RouteBase>[
GoRoute(
path: 'licenses',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const _LicensesPage(),
),
],
),
GoRoute(
path: 'support',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.support),
),
for (final ModuleSettingsSection section in context.settingsSections)
GoRoute(path: section.path, builder: section.builder),
],
),
],
);
/// 설정 첫 화면 자리 — 절 이름과 [로그아웃]
class _SettingsHomePlaceholder extends ConsumerWidget {
const _SettingsHomePlaceholder({required this.sections});
final List<ModuleSettingsSection> sections;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PlaceholderPage(
title: SettingsTexts.home,
links: <PlaceholderLink>[
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();
@override
Widget build(BuildContext context, WidgetRef ref) {
return LicensePage(
applicationName: kAppName,
applicationVersion: ref.watch(appVersionProvider),
);
}
}
@@ -1,27 +1,24 @@
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
///
/// 모델은 손으로 쓴다 — 생성기(freezed · json_serializable)를 넣을 때의 주의는 `flutter/README.md` "build_runner".
class UserModel {
const UserModel({
required this.id,
required this.email,
required this.name,
});
const UserModel({required this.id, required this.email, required this.name});
final int id;
final String email;
final String name;
factory UserModel.fromJson(Map<String, dynamic> json) {
factory UserModel.fromJson(Map<String, Object?> json) {
return UserModel(
id: json['id'] as int,
email: json['email'] as String,
name: json['name'] as String,
id: json['id']! as int,
email: json['email']! as String,
name: json['name']! as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
};
Map<String, Object?> toJson() => <String, Object?>{
'id': id,
'email': email,
'name': name,
};
}
@@ -1,8 +1,7 @@
import 'package:arasystem_app/features/user/data/models/user_model.dart';
import 'package:dio/dio.dart';
import '../models/user_model.dart';
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
/// 사용자 API 호출 구현체 — 목록 페이지네이션(`{items, pageInfo}`)의 예제다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
///
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
@@ -12,7 +11,8 @@ class UserRepository {
final Dio _dio;
Future<UserModel> getUser(int id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
final Response<Map<String, Object?>> response = await _dio
.get<Map<String, Object?>>('/users/$id');
return UserModel.fromJson(response.data!);
}
@@ -21,19 +21,20 @@ class UserRepository {
int limit = 20,
String? cursor,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/users',
queryParameters: {
'limit': limit,
'cursor': ?cursor,
},
);
final Response<Map<String, Object?>> response = await _dio
.get<Map<String, Object?>>(
'/users',
queryParameters: <String, Object?>{'limit': limit, 'cursor': ?cursor},
);
final data = response.data!;
final items = (data['items'] as List<dynamic>)
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
final Map<String, Object?> data = response.data!;
final List<UserModel> items = (data['items']! as List<Object?>)
.map(
(Object? json) => UserModel.fromJson(json! as Map<String, Object?>),
)
.toList();
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
final Map<String, Object?> pageInfo =
data['pageInfo']! as Map<String, Object?>;
return (
items: items,
@@ -1,15 +1,18 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/user/data/models/user_model.dart';
import 'package:arasystem_app/features/user/data/repositories/user_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/api/dio_provider.dart';
import '../../data/models/user_model.dart';
import '../../data/repositories/user_repository.dart';
import 'package:flutter_riverpod/misc.dart';
// 리포지토리 Provider — dioProvider 를 주입받아 생성
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(dioProvider));
});
/// 리포지토리 Provider — dioProvider 를 주입받아 생성 (템플릿 예제)
final Provider<UserRepository> userRepositoryProvider =
Provider<UserRepository>(
(Ref ref) => UserRepository(ref.watch(dioProvider)),
);
// 특정 사용자 조회 Provider (family)
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
return ref.watch(userRepositoryProvider).getUser(id);
});
/// 특정 사용자 조회 Provider (family)
/// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final FutureProviderFamily<UserModel, int> userProvider =
FutureProvider.family<UserModel, int>(
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
);
+40 -54
View File
@@ -1,67 +1,53 @@
import 'package:arasystem_app/app.dart';
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/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.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/theme/app_fonts.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// `.env` 파일 이름 (pubspec assets)
const String _envFileName = '.env';
Future<void> main() async {
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
WidgetsFlutterBinding.ensureInitialized();
// .env 가 없으면 기본 주소로 동작한다 (ApiConfig.fromEnv)
try {
await dotenv.load(fileName: '.env');
} catch (_) {
// .env 파일이 없는 경우 무시 (기본값 사용)
await dotenv.load(fileName: _envFileName);
} on FileNotFoundError {
// 기본값을 쓴다
} on EmptyEnvFileError {
// 기본값을 쓴다
}
AppFonts.registerFontLicenses();
// 토큰 보관 준비 — 첫 실행이면 보안 저장소에 남은 값을 지운다(앱을 띄우기 전에 끝낸다)
final TokenStore tokenStore = TokenStore(
secure: const FlutterSecureStore(),
firstRun: PrefsFirstRunMarker(),
);
await tokenStore.prepare();
final ({String? appVersion, String userAgent}) device =
await loadAppDeviceInfo();
runApp(
// Riverpod Provider Scope 적용
const ProviderScope(
child: MyApp(),
ProviderScope(
// 프로바이더의 실패를 저절로 다시 하지 않는다(core/utils/provider_retry.dart)
retry: noAutomaticRetry,
overrides: [
tokenStoreProvider.overrideWithValue(tokenStore),
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
],
child: const App(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TMA App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomeView(),
);
}
}
class HomeView extends StatelessWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TMA Global Architecture'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Riverpod & Dio Interceptor Setup Complete.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')),
);
},
child: const Text('Show Global Alert Test'),
),
],
),
),
);
}
}
-1
View File
@@ -1 +0,0 @@
// Empty directory placeholder
@@ -0,0 +1,65 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 자리 화면의 줄 하나 — 누르면 [location] 으로 가거나 [onTap] 을 부른다
@immutable
class PlaceholderLink {
const PlaceholderLink({required this.label, this.location, this.onTap});
final String label;
/// 갈 경로 (`context.push` — 뒤로 가기로 돌아온다)
final String? location;
final VoidCallback? onTap;
}
/// 자리 화면 — 화면 이름과(있으면) 갈 곳의 이름만 보인다.
///
/// 화면은 캔버스로 확인받은 뒤 만든다. 확정되면 그 모듈의 라우트 묶음(`features/<모듈>/<모듈>_routes.dart`)의
/// builder 를 그 화면으로 바꾸고, 쓰는 곳이 없어지면 이 위젯을 지운다.
class PlaceholderPage extends StatelessWidget {
const PlaceholderPage({
required this.title,
this.links = const <PlaceholderLink>[],
super.key,
});
final String title;
final List<PlaceholderLink> links;
@override
Widget build(BuildContext context) {
final TextTheme text = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(AppSpacing.s24),
children: <Widget>[
Text(title, textAlign: TextAlign.center, style: text.titleLarge),
if (links.isNotEmpty) const SizedBox(height: AppSpacing.s16),
for (final PlaceholderLink link in links) _LinkTile(link: link),
],
),
),
);
}
}
class _LinkTile extends StatelessWidget {
const _LinkTile({required this.link});
final PlaceholderLink link;
@override
Widget build(BuildContext context) {
final String? location = link.location;
final VoidCallback? onTap = link.onTap;
return ListTile(
title: Text(link.label),
onTap:
onTap ??
(location == null ? null : () => context.push<void>(location)),
);
}
}
@@ -5,8 +5,14 @@
import FlutterMacOS
import Foundation
import device_info_plus
import flutter_secure_storage_darwin
import package_info_plus
import shared_preferences_foundation
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"))
}
+139 -6
View File
@@ -113,6 +113,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: ade1c36f8355d6284c98e4a47992ea263887ed3d1acb281e37635dd45f04c4aa
url: "https://pub.dev"
source: hosted
version: "13.3.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
dio:
dependency: "direct main"
description:
@@ -145,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -174,6 +198,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: "direct main"
description:
@@ -226,10 +255,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -256,6 +285,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea
url: "https://pub.dev"
source: hosted
version: "17.5.0"
hooks:
dependency: transitive
description:
@@ -264,6 +301,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -280,6 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
@@ -408,6 +461,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev"
source: hosted
version: "10.2.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: transitive
description:
@@ -504,6 +573,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -697,10 +822,18 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "6.4.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
@@ -718,5 +851,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.1 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.11.5 <4.0.0"
flutter: ">=3.41.0"
+36 -64
View File
@@ -1,92 +1,64 @@
name: arasystem_app
description: "ARA SYSTEM 그룹웨어 모바일 앱"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# pub.dev 에 올리지 않는 사내 앱이다.
publish_to: 'none'
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
# 앱 판 — build-name(1.0.0)은 Android versionName · iOS CFBundleShortVersionString,
# build-number(+1)는 Android versionCode · iOS CFBundleVersion 이 된다.
# 서버에 보내는 User-Agent 의 판(ARASYSTEM-App/<판>)도 build-name 이다 (core/api/user_agent.dart).
version: 1.0.0+1
environment:
sdk: ^3.11.1
# Flutter 3.41.9 의 Dart — 판 고정은 .claude/skills/flutter/SKILL.md "Flutter 판"
sdk: ^3.11.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
# 새 패키지는 pub.dev 배포 뒤 2주가 지난 판만 고른다 (보안 규칙 "의존성"의 2주 규칙과 같다).
dependencies:
flutter:
sdk: flutter
# 한국어 시스템 문구(복사 · 붙여 넣기 메뉴 · 날짜 고르기) — locale: ko
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# 기기 정보(Android 판 · 기종) — User-Agent 만들기
device_info_plus: ^13.3.0
dio: ^5.9.2
flutter_dotenv: ^5.2.1
flutter_riverpod: ^3.3.1
flutter_secure_storage: ^10.0.0
# 화면 이동 — redirect 하나가 세션 상태로 화면을 고른다 (core/router/)
go_router: ^17.5.0
# 날짜 · 숫자 형식 — flutter_localizations 가 이 판으로 고정한다
intl: 0.20.2
# 앱 판 — User-Agent 만들기
package_info_plus: ^10.2.2
# 첫 실행 표시 하나만 둔다 — 토큰은 여기에 두지 않는다 (core/storage/first_run_marker.dart)
shared_preferences: ^2.5.6
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# 런타임 환경변수 — flutter_dotenv 로 로드
assets:
# 런타임 환경변수 — flutter_dotenv 로 읽는다 (API_URL 만 — 앱 안에 그대로 들어가니 비밀은 넣지 않는다)
- .env
# 글꼴 라이선스 — 설정의 [오픈소스 라이선스]에 보인다 (core/theme/app_fonts.dart)
- assets/licenses/pretendard-ofl.txt
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
# Pretendard 1.3.9 (SIL OFL 1.1) — 웹(frontend)과 같은 글꼴. 정적 OTF 넷만 넣는다
fonts:
- family: Pretendard
fonts:
- asset: assets/fonts/Pretendard-Regular.otf
weight: 400
- asset: assets/fonts/Pretendard-Medium.otf
weight: 500
- asset: assets/fonts/Pretendard-SemiBold.otf
weight: 600
- asset: assets/fonts/Pretendard-Bold.otf
weight: 700
+271
View File
@@ -0,0 +1,271 @@
import 'package:arasystem_app/app.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/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/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/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.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';
void main() {
late ProviderHarness h;
setUp(() => h = ProviderHarness());
Future<void> pumpApp(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
retry: noAutomaticRetry,
overrides: h.overrides,
child: const App(),
),
);
await tester.pumpAndSettle();
}
group('앱 뿌리 · 라우터', () {
testWidgets('세션이 없으면 로그인 화면으로 간다 — 한국어 · 앱 이름', (
WidgetTester tester,
) async {
await h.prepare();
await pumpApp(tester);
expect(find.text(ScreenTitles.login), 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),
);
expect(Localizations.localeOf(context).languageCode, 'ko');
});
testWidgets('되살린 세션에 새 비밀번호 제한이 있으면 그 단계 화면이다', (
WidgetTester tester,
) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
await pumpApp(tester);
expect(find.text(ScreenTitles.passwordChange), findsOneWidget);
});
testWidgets('되살린 세션에 제한이 없으면 메일 화면이다', (WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
await pumpApp(tester);
expect(
find.descendant(
of: find.byType(ListView),
matching: find.text(MailTexts.home),
),
findsOneWidget,
);
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('되살리기가 장애로 실패하면 시작 화면에 남는다', (WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
await pumpApp(tester);
expect(find.text(ScreenTitles.start), findsOneWidget);
});
});
group('모듈 · 탭 막대', () {
Future<GoRouter> pumpSignedIn(WidgetTester tester) async {
await h.prepare(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
'tokens': tokensJson(2, now: h.clock.now),
}),
);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
await pumpApp(tester);
return ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
).read(routerProvider);
}
Finder tab(String label) => find.descendant(
of: find.byType(NavigationBar),
matching: find.text(label),
);
Finder page(String title) =>
find.descendant(of: find.byType(ListView), matching: find.text(title));
testWidgets('켜진 모듈만 탭이 된다 — 메일 · 설정 차례', (WidgetTester tester) async {
await pumpSignedIn(tester);
final NavigationBar bar = tester.widget(find.byType(NavigationBar));
expect(bar.destinations, hasLength(2));
expect(tab('메일'), findsOneWidget);
expect(tab('설정'), findsOneWidget);
expect(tab('캘린더'), findsNothing);
expect(tab('주소록'), findsNothing);
expect(
tester.getTopLeft(tab('메일')).dx,
lessThan(tester.getTopLeft(tab('설정')).dx),
);
});
testWidgets('탭을 바꿨다 돌아와도 메일 탭의 이동 기록이 남는다', (WidgetTester tester) async {
final GoRouter router = await pumpSignedIn(tester);
router.go(MailRoutes.mailbox('archive'));
await tester.pumpAndSettle();
expect(page(MailTexts.mailbox), findsOneWidget);
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
expect(page(SettingsTexts.home), findsOneWidget);
await tester.tap(tab('메일'));
await tester.pumpAndSettle();
expect(page(MailTexts.mailbox), findsOneWidget);
expect(
router.routerDelegate.currentConfiguration.uri.path,
MailRoutes.mailbox('archive'),
);
// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로
await tester.tap(tab('메일'));
await tester.pumpAndSettle();
expect(page(MailTexts.home), findsOneWidget);
});
testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
final GoRouter router = await pumpSignedIn(tester);
router.go(MailRoutes.message('m1'));
await tester.pumpAndSettle();
expect(page(MailTexts.message), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets('알 수 없는 모듈 · 꺼진 모듈 · 없는 주소는 메일 첫 화면으로 간다', (
WidgetTester tester,
) async {
final GoRouter router = await pumpSignedIn(tester);
for (final String location in <String>[
'/calendar',
'/contacts/c1',
'/bogus',
'/mail/nowhere/at-all',
]) {
router.go(MailRoutes.mailbox('archive'));
await tester.pumpAndSettle();
router.go(location);
await tester.pumpAndSettle();
expect(
router.routerDelegate.currentConfiguration.uri.path,
MailRoutes.home,
reason: location,
);
expect(page(MailTexts.home), findsOneWidget, reason: location);
}
});
testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', (
WidgetTester tester,
) async {
final GoRouter router = await pumpSignedIn(tester);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await tester.tap(tab('설정'));
await tester.pumpAndSettle();
for (final String item in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
SettingsTexts.signOut,
]) {
expect(page(item), findsOneWidget, reason: item);
}
await tester.tap(page(SettingsTexts.signOut));
await tester.pumpAndSettle();
expect(find.text(ScreenTitles.login), findsOneWidget);
expect(router.routerDelegate.currentConfiguration.uri.path, '/login');
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
});
});
group('전역 알림', () {
testWidgets('API 실패는 전역 ScaffoldMessenger 의 스낵바로 사전 문구를 보인다', (
WidgetTester tester,
) async {
final GlobalKey<ScaffoldMessengerState> key =
GlobalKey<ScaffoldMessengerState>();
final ErrorNotifier notifier = ErrorNotifier(messengerKey: key);
await tester.pumpWidget(
MaterialApp(
scaffoldMessengerKey: key,
home: const Scaffold(body: SizedBox.shrink()),
),
);
notifier.show(ApiException.fromCode('MAIL_001'));
await tester.pump();
expect(find.text(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget);
});
testWidgets('앱의 알림 자리가 MaterialApp 에 걸려 있다', (WidgetTester tester) async {
await h.prepare();
await pumpApp(tester);
final MaterialApp app = tester.widget(find.byType(MaterialApp));
final ProviderContainer container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
);
expect(
app.scaffoldMessengerKey,
container.read(scaffoldMessengerKeyProvider),
);
});
});
}
@@ -0,0 +1,974 @@
import 'dart:async';
import 'dart:convert';
import 'package:arasystem_app/core/api/api_exception.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/session_events.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
/// 보호 API 하나 — 시험용
const String _mailState = '/mail-state';
void main() {
late ApiHarness api;
setUp(() async {
api = ApiHarness();
await api.prepare();
});
/// 지금 액세스 토큰이면 200, 아니면 401 인 보호 API
void protectedRoute({required String validToken}) {
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == validToken
? FakeResponse.ok(<String, Object?>{'state': 's1'})
: FakeResponse.fail(401, 'AUTH_001');
});
}
/// 갱신할 때마다 다음 토큰(2 · 3 · …)을 주는 서버 — 서버 시각은 [serverNow] 로 센다
void refreshIssuesNext({DateTime Function()? serverNow}) {
int next = 2;
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(next++, now: serverNow?.call() ?? api.clock.now),
}),
);
}
int refreshCount() => api.server.received('POST', ApiPaths.appRefresh).length;
group('토큰 붙이기 · 미리 갱신', () {
test('로그인한 요청에 Bearer 와 앱 User-Agent 를 싣고 응답을 푼다', () async {
await api.signIn();
protectedRoute(validToken: 'access-1');
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final RequestOptions sent = api.server.requests.single;
expect(bearerTokenOf(sent), 'access-1');
expect(sent.headers['User-Agent'], kTestUserAgent);
});
test('앱을 새로 띄워 액세스 토큰이 없으면 보내기 전에 한 번 갱신한다(401 을 보지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
expect(
api.server.received('POST', ApiPaths.appRefresh).single.data,
<String, Object?>{'refreshToken': 'refresh-1'},
);
expect(api.server.received('GET', _mailState), hasLength(1));
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
expect(api.emitted.whereType<SessionRefreshed>(), hasLength(1));
});
test('액세스 토큰이 곧 끝나면(30초 안) 미리 갱신한다', () async {
await api.signIn();
api.clock.advance(const Duration(minutes: 59, seconds: 40));
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
expect(
bearerTokenOf(api.server.received('GET', _mailState).single),
'access-2',
);
});
test('기기 시계가 서버보다 2시간 빨라도 응답의 Date 로 남은 시간을 재 요청마다 갱신하지 않는다', () async {
final DateTime serverNow = api.clock.now.subtract(
const Duration(hours: 2),
);
api.server.serverTime = serverNow;
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext(serverNow: () => serverNow);
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.ok(<String, Object?>{'state': 's1'}),
);
await api.dio.get<Object?>(_mailState);
await api.dio.get<Object?>(_mailState);
expect(refreshCount(), 1);
// 받은 때부터 59분 31초 — 만료 30초 전이라 다시 갱신한다
api.clock.advance(const Duration(minutes: 59, seconds: 31));
await api.dio.get<Object?>(_mailState);
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');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.server.received('GET', _mailState), isEmpty);
expect(api.notifier.shownCodes, <String>['AUTH_002']);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
expect(api.store.hasSession, isFalse);
});
test('미리 갱신이 장애면 원래 요청을 보내지 않고 그 코드로 알리며 세션은 둔다', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
protectedRoute(validToken: 'access-2');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(api.server.received('GET', _mailState), isEmpty);
expect(api.notifier.shownCodes, <String>['MAIL_001']);
expect(api.store.hasSession, isTrue);
});
test('연결이 끊긴 동안 줄 선 요청들은 갱신을 한 번만 보내고, 5초가 지나면 다시 묻는다', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
protectedRoute(validToken: 'access-2');
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code), <String>[
'SYS_001',
'SYS_001',
'SYS_001',
]);
expect(refreshCount(), 1);
expect(api.notifier.shownCodes, <String>['SYS_001']);
api.clock.advance(const Duration(seconds: 5));
await errorOf(api.dio.get<Object?>(_mailState));
expect(refreshCount(), 2);
});
});
group('401 → 갱신 → 다시', () {
test('401 이면 갱신을 한 번 하고 원래 요청을 새 토큰으로 한 번 다시 보낸다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final List<RequestOptions> calls = api.server.received('GET', _mailState);
expect(calls.map(bearerTokenOf), <String?>['access-1', 'access-2']);
expect(refreshCount(), 1);
expect(api.store.accessToken, 'access-2');
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
expect(api.notifier.shownCodes, isEmpty);
});
test('동시에 401 을 받은 요청 셋은 갱신 한 번에 묶이고 모두 성공한다', () async {
await api.signIn();
final Completer<void> allRejected = Completer<void>();
int rejected = 0;
api.server.on('GET', _mailState, (RequestOptions options) {
if (bearerTokenOf(options) == 'access-2') {
return FakeResponse.ok(<String, Object?>{'state': 's1'});
}
rejected++;
if (rejected == 3) allRejected.complete();
return FakeResponse.fail(401, 'AUTH_001');
});
api.server.on('POST', ApiPaths.appRefresh, (_) async {
// 세 요청이 모두 401 을 받은 뒤에야 갱신이 끝난다
await allRejected.future;
return FakeResponse.ok(<String, Object?>{'tokens': tokensJson(2)});
});
final List<Response<Object?>> responses =
await Future.wait(<Future<Response<Object?>>>[
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
]);
expect(responses.map((Response<Object?> r) => r.statusCode), <int?>[
200,
200,
200,
]);
expect(refreshCount(), 1);
expect(api.server.received('GET', _mailState), hasLength(6));
});
test('FormData 본문은 다시 보낼 때 새로 만든다(한 번 보낸 FormData 는 다시 쓸 수 없다)', () async {
await api.signIn();
refreshIssuesNext();
api.server.on('POST', '/mail-attachments', (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.ok(<String, Object?>{'id': 'a1'})
: FakeResponse.fail(401, 'AUTH_001');
});
final Response<Map<String, Object?>> response = await api.dio
.post<Map<String, Object?>>(
'/mail-attachments',
data: FormData.fromMap(<String, Object?>{
'note': '첨부',
'file': MultipartFile.fromString('hello', filename: 'a.txt'),
}),
);
expect(response.data, <String, Object?>{'id': 'a1'});
final List<RequestOptions> sent = api.server.received(
'POST',
'/mail-attachments',
);
expect(sent, hasLength(2));
final FormData first = sent.first.data! as FormData;
final FormData second = sent.last.data! as FormData;
expect(identical(first, second), isFalse);
expect(second.fields, first.fields);
expect(second.files.single.value.filename, 'a.txt');
});
test('갱신이 401 이면 토큰을 지우고 AUTH_002 로 알리며 세션 끝을 알린다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.store.accessToken, isNull);
expect(api.store.hasSession, isFalse);
expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
expect(api.notifier.shownCodes, <String>['AUTH_002']);
// 원래 요청은 다시 보내지 않았다
expect(api.server.received('GET', _mailState), hasLength(1));
});
test('동시에 세션이 끝난 요청들의 AUTH_002 알림은 한 번만 뜬다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
protectedRoute(validToken: 'never');
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code), <String>[
'AUTH_002',
'AUTH_002',
]);
expect(refreshCount(), 1);
expect(api.notifier.shownCodes, <String>['AUTH_002']);
});
test('갱신이 일시 장애(503 · 연결 끊김)면 세션을 두고 그 코드로 알린다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(api.store.hasSession, isTrue);
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, <String>['MAIL_001']);
// 5초 안에는 같은 실패를 다시 쓴다 — 지난 뒤에 다시 묻는다
api.clock.advance(const Duration(seconds: 5));
api.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
final ApiException offline = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(offline.code, 'SYS_001');
expect(api.store.hasSession, isTrue);
expect(refreshCount(), 2);
});
test('새 토큰으로 다시 보낸 요청도 401 이면 세션을 끝낸다(한 번만 다시 보낸다)', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.server.received('GET', _mailState), hasLength(2));
expect(refreshCount(), 1);
expect(api.store.hasSession, isFalse);
expect(api.emitted.whereType<SessionEnded>(), hasLength(1));
});
test('갱신 유예 응답(tokens: null)은 이어 쓸 토큰이 없어 세션 끝으로 다룬다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': null,
}),
);
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_002');
expect(api.store.hasSession, isFalse);
});
test('갱신하는 사이 로그아웃하면 받은 토큰을 쓰지 않고 그 세션을 Bearer 와 함께 끝내며 알리지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('POST', ApiPaths.appRefresh, (_) async {
await release.future;
return FakeResponse.ok(<String, Object?>{'tokens': tokensJson(2)});
});
api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
protectedRoute(validToken: 'access-2');
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
// 갱신이 서버에 닿을 때까지 기다린 뒤 로그아웃한다
await pumpEventsUntil(() => refreshCount() == 1, reason: '갱신이 서버에 닿기');
await api.store.clear(byUser: true);
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.store.accessToken, isNull);
expect(api.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(
() => api.server.received('POST', ApiPaths.appLogout).isNotEmpty,
reason: '조용한 로그아웃',
);
final RequestOptions logout = api.server
.received('POST', ApiPaths.appLogout)
.single;
expect(logout.data, <String, Object?>{'refreshToken': 'refresh-2'});
expect(bearerTokenOf(logout), 'access-2');
expect(api.notifier.shownCodes, isEmpty);
});
test(
'사용자가 로그아웃한 뒤 늦게 온, 로그인 중에 보낸 요청의 401 은 승격하지 않고(AUTH_001) 알리지 않는다',
() async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
final Future<Object> pending = errorOf(
api.dio.get<Object?>(_mailState),
);
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '요청이 서버에 닿기',
);
await api.store.clear(byUser: true);
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.notifier.shownCodes, isEmpty);
expect(refreshCount(), 0);
expect(api.store.signedOutByUser, isTrue);
},
);
});
group('갱신하지 않는 경우', () {
test('로그인 · 갱신 · 로그아웃 요청의 401 은 갱신하지 않는다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
final ApiException error = apiErrorOf(
await errorOf(api.dio.post<Object?>(ApiPaths.appLogin)),
);
expect(error.code, 'AUTH_004');
expect(refreshCount(), 0);
expect(api.store.hasSession, isTrue);
// 로그인 요청에는 Bearer 를 싣지 않는다
expect(bearerTokenOf(api.server.requests.single), isNull);
});
test('세션 없이 받은 401 은 AUTH_001 그대로다(세션 만료로 올리지 않는다)', () async {
protectedRoute(validToken: 'never');
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_001');
expect(refreshCount(), 0);
expect(api.emitted, isEmpty);
});
});
group('AUTH_010 — 제한 다시 맞추기', () {
/// [attempts] 번째 요청까지 AUTH_010, 그 뒤는 성공하는 보호 API
void restrictedUntil(int attempts) {
int seen = 0;
api.server.on('GET', _mailState, (_) {
seen++;
return seen <= attempts
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.ok(<String, Object?>{'state': 's1'});
});
}
test('제한을 확인했으면(restricted) 다시 보내지 않고 알리지 않는다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
restrictedUntil(1);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.server.received('GET', _mailState), hasLength(1));
expect(api.notifier.shownCodes, isEmpty);
expect(api.emitted.whereType<SessionRecheckNeeded>(), hasLength(1));
expect(refreshCount(), 0);
});
test('옛 토큰이었으면(renewed) 원래 요청을 한 번 다시 보낸다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.renewed;
restrictedUntil(1);
final Response<Map<String, Object?>> response = await api.dio
.get<Map<String, Object?>>(_mailState);
expect(response.data, <String, Object?>{'state': 's1'});
final List<RequestOptions> sent = api.server.received('GET', _mailState);
expect(sent, hasLength(2));
expect(sent.last.extra[kRestrictionRetriedExtraKey], isTrue);
expect(api.notifier.shownCodes, isEmpty);
});
test('다시 보낸 요청도 AUTH_010 이면 다시 맞추지 않고 알린다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.renewed;
restrictedUntil(2);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.server.received('GET', _mailState), hasLength(2));
expect(api.emitted.whereType<SessionRecheckNeeded>(), hasLength(1));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
});
test(
'확인하지 못했으면(unknown) 다시 보내지 않고 알린다 — 그사이 세션이 바뀌었으면(stale) 알리지 않는다',
() async {
await api.signIn();
restrictedUntil(99);
api.recheckAnswer = RestrictionCheck.unknown;
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
api.clock.advance(const Duration(seconds: 5));
api.recheckAnswer = RestrictionCheck.stale;
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['AUTH_010']);
expect(api.server.received('GET', _mailState), hasLength(2));
},
);
test('듣는 쪽(세션 노티파이어)이 없으면 확인하지 못한 것으로 보고 알린다', () async {
final ApiHarness alone = ApiHarness();
await alone.store.prepare();
await alone.store.save(tokensFor(1, now: alone.clock.now));
alone.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
final ApiException error = apiErrorOf(
await errorOf(alone.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(alone.notifier.shownCodes, <String>['AUTH_010']);
});
test('사용자가 로그아웃한 뒤 늦게 온 AUTH_010 은 다시 맞추지 않고 알리지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(403, 'AUTH_010');
});
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '요청이 서버에 닿기',
);
await api.store.clear(byUser: true);
release.complete();
expect(apiErrorOf(await pending).code, 'AUTH_010');
expect(api.emitted.whereType<SessionRecheckNeeded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
});
group('응답 풀기 · 알림 끄기', () {
test('성공한 바이트 · 스트림 응답은 풀지 않고 그대로 준다(첨부 받기)', () async {
await api.signIn();
api.server.on(
'GET',
'/files/f1',
(_) => FakeResponse.ok(<String, Object?>{'not': 'unwrapped'}),
);
final Response<List<int>> bytes = await api.dio.get<List<int>>(
'/files/f1',
options: Options(responseType: ResponseType.bytes),
);
expect(jsonDecode(utf8.decode(bytes.data!)), <String, Object?>{
'success': true,
'data': <String, Object?>{'not': 'unwrapped'},
});
final Response<ResponseBody> stream = await api.dio.get<ResponseBody>(
'/files/f1',
options: Options(responseType: ResponseType.stream),
);
final List<int> streamed = <int>[
for (final List<int> chunk in await stream.data!.stream.toList())
...chunk,
];
expect(utf8.decode(streamed), contains('"success":true'));
});
test('silent: true 면 알리지 않지만 코드는 그대로 온다', () async {
await api.signIn();
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
final ApiException error = apiErrorOf(
await errorOf(
api.dio.get<Object?>(
_mailState,
options: Options(extra: <String, Object?>{kSilentExtraKey: true}),
),
),
);
expect(error.code, 'RES_001');
expect(api.notifier.shownCodes, isEmpty);
});
test('silent 에 코드 목록을 주면 그 코드만 알리지 않는다', () async {
await api.signIn();
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
api.server.on('GET', '/other', (_) => FakeResponse.fail(429, 'REQ_001'));
await errorOf(
api.dio.get<Object?>(
_mailState,
options: ApiOptions.silentFor(<String>{'RES_001'}),
),
);
await errorOf(
api.dio.get<Object?>(
'/other',
options: ApiOptions.silentFor(<String>{'RES_001'}),
),
);
expect(api.notifier.shownCodes, <String>['REQ_001']);
});
test('같은 코드는 3초 안에 다시 알리지 않고, 지나면 다시 알린다', () async {
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(429, 'REQ_001'),
);
await errorOf(api.dio.get<Object?>(_mailState));
api.clock.advance(const Duration(seconds: 2));
await errorOf(api.dio.get<Object?>(_mailState));
api.clock.advance(const Duration(seconds: 2));
await errorOf(api.dio.get<Object?>(_mailState));
expect(api.notifier.shownCodes, <String>['REQ_001', 'REQ_001']);
});
test('200 에 success: false 가 와도 실패로 바꿔 코드로 알린다', () async {
api.server.on(
'GET',
_mailState,
(_) => const FakeResponse(200, <String, Object?>{
'success': false,
'error': <String, Object?>{'code': 'MAIL_001', 'traceId': 't-1'},
}),
);
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'MAIL_001');
expect(error.traceId, 't-1');
expect(api.notifier.shownCodes, <String>['MAIL_001']);
});
});
group('다시 보낸 요청 · 세션을 넘는 응답', () {
test('401 뒤 다시 보낸 요청이 AUTH_010 이면 제한을 다시 맞추고 확인했으면 알리지 않는다', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: 'passwordChange'),
'tokens': tokensJson(2),
}),
);
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.fail(401, 'AUTH_001');
});
final ApiException error = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(error.code, 'AUTH_010');
expect(api.recheckCount, 1);
expect(api.server.received('GET', _mailState), hasLength(2));
expect(api.notifier.shownCodes, isEmpty);
});
test('다시 보낸 요청이 401 인데 그사이 사용자가 로그아웃했으면 세션 만료로 알리지 않는다', () async {
await api.signIn();
refreshIssuesNext();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (RequestOptions options) async {
if (bearerTokenOf(options) == 'access-2') await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).length == 2,
reason: '다시 보낸 요청이 서버에 닿기',
);
await api.store.signOut();
release.complete();
final ApiException error = apiErrorOf(await pending);
expect(error.code, 'AUTH_001');
expect(api.store.signedOutByUser, isTrue);
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
test('로그아웃 뒤 다른 직원이 로그인했으면 앞 세션의 늦은 401 을 새 토큰으로 다시 보내지 않는다', () async {
await api.signIn();
final Completer<void> release = Completer<void>();
api.server.on('GET', _mailState, (_) async {
await release.future;
return FakeResponse.fail(401, 'AUTH_001');
});
refreshIssuesNext();
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
await pumpEventsUntil(
() => api.server.received('GET', _mailState).isNotEmpty,
reason: '앞 직원의 요청이 서버에 닿기',
);
await api.store.signOut();
await api.store.save(tokensFor(5, now: api.clock.now));
release.complete();
expect(apiErrorOf(await pending).code, 'AUTH_001');
expect(api.server.received('GET', _mailState), hasLength(1));
expect(refreshCount(), 0);
expect(api.store.accessToken, 'access-5');
expect(api.notifier.shownCodes, isEmpty);
});
test('같은 토큰으로 줄 선 AUTH_010 셋은 제한을 한 번만 묻는다(제한을 확인한 결과를 다시 쓴다)', () async {
await api.signIn();
api.recheckAnswer = RestrictionCheck.restricted;
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
final List<Object> errors = await Future.wait(<Future<Object>>[
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
errorOf(api.dio.get<Object?>(_mailState)),
]);
expect(errors.map((Object e) => apiErrorOf(e).code).toSet(), <String>{
'AUTH_010',
});
expect(api.recheckCount, 1);
expect(api.notifier.shownCodes, isEmpty);
});
test('앞 요청이 다시 맞춰 새 토큰을 받았으면 줄 선 AUTH_010 은 묻지 않고 다시 보낸다', () async {
await api.signIn();
api.onRecheck = () async {
await api.store.save(tokensFor(2, now: api.clock.now));
return RestrictionCheck.renewed;
};
api.server.on('GET', _mailState, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.ok(<String, Object?>{'state': 's1'})
: FakeResponse.fail(403, 'AUTH_010');
});
final List<Response<Object?>> responses =
await Future.wait(<Future<Response<Object?>>>[
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
api.dio.get<Object?>(_mailState),
]);
expect(
responses.map((Response<Object?> r) => r.statusCode).toSet(),
<int?>{200},
);
expect(api.recheckCount, 1);
expect(api.server.received('GET', _mailState), hasLength(6));
});
test('갱신한 토큰을 보안 저장소에 두지 못해도 이번 실행은 메모리의 새 토큰으로 이어 가고 다음에 다시 쓴다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
api.secure.failWrites = true;
final Response<Object?> response = await api.dio.get<Object?>(_mailState);
expect(response.statusCode, 200);
expect(api.store.accessToken, 'access-2');
expect(api.store.hasSession, isTrue);
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.notifier.shownCodes, isEmpty);
// 저장소가 돌아오면 다음 읽기 때 메모리의 새 토큰을 다시 쓴다
api.secure.failWrites = false;
expect(await api.store.readRefreshToken(), 'refresh-2');
expect(api.secure.values[kRefreshTokenKey], 'refresh-2');
});
test('리프레시 토큰을 읽는 사이 로그아웃하면 세션 끝으로 다루지 않는다(알리지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
final Completer<void> gate = Completer<void>();
api.secure.readGate = gate;
final int readsBefore = api.secure.readCalls;
final Future<Object> pending = errorOf(api.dio.get<Object?>(_mailState));
// 갱신이 리프레시 토큰을 읽으려고 기다리는 동안 로그아웃한다
await pumpEventsUntil(
() => api.secure.readCalls > readsBefore,
reason: '갱신이 리프레시 토큰을 읽기 시작',
);
final Future<SignOutTokens> signingOut = api.store.signOut();
api.secure.readGate = null;
gate.complete();
await signingOut;
expect(apiErrorOf(await pending).code, 'AUTH_001');
expect(refreshCount(), 0);
expect(api.store.signedOutByUser, isTrue);
expect(api.emitted.whereType<SessionEnded>(), isEmpty);
expect(api.notifier.shownCodes, isEmpty);
});
test('유예 응답(tokens: null)이면 메모리의 액세스 토큰으로 서버 세션도 조용히 끝낸다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': null,
}),
);
api.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
protectedRoute(validToken: 'never');
expect(
apiErrorOf(await errorOf(api.dio.get<Object?>(_mailState))).code,
'AUTH_002',
);
await pumpEventsUntil(
() => api.server.received('POST', ApiPaths.appLogout).isNotEmpty,
reason: '조용한 로그아웃',
);
final RequestOptions logout = api.server
.received('POST', ApiPaths.appLogout)
.single;
expect(logout.data, <String, Object?>{});
expect(bearerTokenOf(logout), 'access-1');
});
test('갱신 알림 · 결과에는 세션 칸만 싣고 토큰은 싣지 않는다', () async {
await api.signIn();
refreshIssuesNext();
protectedRoute(validToken: 'access-2');
await api.dio.get<Object?>(_mailState);
final SessionRefreshed refreshed = api.emitted
.whereType<SessionRefreshed>()
.single;
expect(refreshed.body.keys.toSet(), kSessionFieldNames);
expect(refreshed.body.containsKey('tokens'), isFalse);
});
test('비밀이 든 요청의 실패 예외는 본문과 Bearer 를 비운 채 온다', () async {
await api.signIn();
api.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
api.server.on(
'GET',
_mailState,
(_) => FakeResponse.fail(404, 'RES_001'),
);
const Map<String, Object?> loginBody = <String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'password': 'secret-pw',
};
final Object login = await errorOf(
api.dio.post<Object?>(
ApiPaths.appLogin,
data: loginBody,
options: ApiOptions.secret(silent: true),
),
);
final Object plain = await errorOf(api.dio.get<Object?>(_mailState));
final DioException loginError = login as DioException;
expect(loginError.requestOptions.data, isNull);
expect(loginError.response?.requestOptions.data, isNull);
final DioException plainError = plain as DioException;
expect(
plainError.requestOptions.headers.containsKey(kAuthorizationHeader),
isFalse,
);
// 서버에는 그대로 갔다(보낸 그때의 모습)
expect(
api.server.received('POST', ApiPaths.appLogin).single.data,
loginBody,
);
});
test('인터셉터 안에서 뜻밖의 예외가 나도 요청은 SYS_001 로 끝난다(줄이 서지 않는다)', () async {
await api.coldStartWithRefreshToken('refresh-1');
api.secure.throwOnRead = StateError('뜻밖의 오류(시험)');
protectedRoute(validToken: 'access-2');
final ApiException beforeSend = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(beforeSend.code, 'SYS_001');
expect(api.server.received('GET', _mailState), isEmpty);
// 401 뒤의 갱신에서 나도 같다
api.secure.throwOnRead = null;
await api.store.save(tokensFor(1, now: api.clock.now));
api.secure.throwOnRead = StateError('뜻밖의 오류(시험)');
final ApiException afterError = apiErrorOf(
await errorOf(api.dio.get<Object?>(_mailState)),
);
expect(afterError.code, 'SYS_001');
expect(api.server.received('GET', _mailState), hasLength(1));
});
});
}
@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('제한 다시 맞추기 요청', () {
test('듣는 쪽이 없으면 기다리지 않고 unknown 이다', () async {
final SessionEventBus bus = SessionEventBus();
addTearDown(bus.close);
expect(await bus.requestRecheck(), RestrictionCheck.unknown);
});
test('듣는 쪽이 답하면 그 답이다', () async {
final SessionEventBus bus = SessionEventBus();
addTearDown(bus.close);
bus.stream.listen((SessionEvent event) {
if (event is SessionRecheckNeeded) {
event
..reply(RestrictionCheck.restricted)
// 두 번째 답은 쓰지 않는다
..reply(RestrictionCheck.renewed);
}
});
expect(await bus.requestRecheck(), RestrictionCheck.restricted);
});
testWidgets('듣는 쪽이 답하지 않으면 한도가 지나 unknown 이다(손으로 돌리는 시계)', (
WidgetTester tester,
) async {
final SessionEventBus bus = SessionEventBus();
bus.stream.listen((_) {});
RestrictionCheck? answer;
unawaited(
bus.requestRecheck().then((RestrictionCheck check) => answer = check),
);
await tester.pump(
kRestrictionRecheckTimeout - const Duration(seconds: 1),
);
expect(answer, isNull);
await tester.pump(const Duration(seconds: 1));
expect(answer, RestrictionCheck.unknown);
unawaited(bus.close());
});
test('한도는 안쪽 Dio 두 요청(세션 확인 · 갱신)의 연결 · 받기 한도를 다 쓴 것보다 길다', () {
expect(
kRestrictionRecheckTimeout >
(kApiConnectTimeout + kApiReceiveTimeout) * 2,
isTrue,
);
});
});
test('갱신 응답에서 세션 칸만 남긴다(토큰을 빼고)', () {
expect(
sessionFieldsOf(<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': null,
'twoFactorDeadline': null,
'tokens': <String, Object?>{'refreshToken': 'r'},
'other': 1,
}),
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': null,
'twoFactorDeadline': null,
},
);
});
}
@@ -0,0 +1,47 @@
import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('User-Agent', () {
test('계약 1절 "앱 로그인"의 꼴로 만든다', () {
expect(
buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
osVersion: '15',
model: 'SM-S921N',
),
'ARASYSTEM-App/1.0.0 (Android 15; SM-S921N)',
);
});
test('모르는 칸은 빼고 아는 것만 싣는다', () {
expect(
buildUserAgent(appVersion: null, osName: 'Android', osVersion: '15'),
'ARASYSTEM-App (Android 15)',
);
expect(
buildUserAgent(appVersion: '1.0.0', osName: ''),
'ARASYSTEM-App/1.0.0',
);
});
test('머리글을 깨는 글자(괄호 · 쌍반점 · 한글)는 지우고 길이를 줄인다', () {
final String agent = buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
osVersion: '15',
model: '갤럭시 (S24); Ultra',
);
expect(agent, 'ARASYSTEM-App/1.0.0 (Android 15; S24 Ultra)');
final String long = buildUserAgent(
appVersion: '1.0.0',
osName: 'Android',
model: 'X' * 200,
);
expect(long.length, lessThan(120));
expect(RegExp(r'^[\x20-\x7E]+$').hasMatch(long), isTrue);
});
});
}
@@ -0,0 +1,150 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/push/push_target.dart';
import 'package:arasystem_app/core/router/module_registry.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
void main() {
group('모듈 목록', () {
test('웹 app-modules.ts 와 같은 id · 이름 · 차례다(설정은 맨 끝)', () {
// 짝: frontend/src/shared/constants/app-modules.ts 의 APP_MODULES — 한쪽을 바꾸면 둘 다
expect(
kAppModules.map((AppModule m) => '${m.id.name}:${m.label}').toList(),
<String>[
'mail:메일',
'calendar:캘린더',
'files:파일',
'contacts:주소록',
'settings:설정',
],
);
});
test('모든 모듈 id 가 목록에 정확히 한 번 있다', () {
for (final AppModuleId id in AppModuleId.values) {
expect(
kAppModules.where((AppModule m) => m.id == id),
hasLength(1),
reason: id.name,
);
}
expect(kAppModules, hasLength(AppModuleId.values.length));
});
test('홈은 넘겨받은 목록을 본다 — 메일이 꺼졌으면 켜진 첫 모듈', () {
final List<AppModule> mailOff = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.mail
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: false,
)
: m,
];
expect(homeLocationOf(), '/mail');
expect(homeLocationOf(mailOff), '/settings');
});
test('첫 판은 메일 · 설정만 켠다 — 켜진 것만 목록 차례대로', () {
expect(
enabledAppModules().map((AppModule m) => m.id).toList(),
<AppModuleId>[AppModuleId.mail, AppModuleId.settings],
);
});
test('주소는 모듈 이름부터 시작하고, 경로의 첫 칸으로 켜진 모듈을 찾는다', () {
expect(findAppModule(AppModuleId.mail).rootPath, '/mail');
expect(homeLocation, '/mail');
expect(enabledModuleOfPath('/mail/messages/1')?.id, AppModuleId.mail);
expect(enabledModuleOfPath('/settings')?.id, AppModuleId.settings);
expect(enabledModuleOfPath('/calendar'), isNull);
expect(enabledModuleOfPath('/bogus'), isNull);
expect(enabledModuleOfPath('/'), isNull);
});
test('켜진 모듈은 모두 라우트 묶음이 있고, 첫 라우트가 모듈 경로다', () {
final List<ModuleRoutes> routes = enabledModuleRoutes();
expect(routes.map((ModuleRoutes r) => r.id).toList(), <AppModuleId>[
AppModuleId.mail,
AppModuleId.settings,
]);
for (final ModuleRoutes module in routes) {
final List<RouteBase> built = module.buildRoutes(
ModuleRouteContext(rootNavigatorKey: GlobalKey<NavigatorState>()),
);
expect(
(built.first as GoRoute).path,
findAppModule(module.id).rootPath,
reason: module.id.name,
);
}
});
test('켰는데 라우트 묶음이 없는 모듈은 라우터를 만들 때 멈춘다', () {
final List<AppModule> withCalendar = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.calendar
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: true,
)
: m,
];
expect(() => enabledModuleRoutes(withCalendar), throwsStateError);
});
});
group('알림 · 깊은 링크', () {
test('모듈 이름과 id 로 화면을 고른다', () {
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{'module': 'mail', 'id': 'm 1'}),
moduleRoutesOf,
),
MailRoutes.message('m 1'),
);
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{'module': 'mail'}),
moduleRoutesOf,
),
'/mail',
);
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{
'module': 'settings',
'id': 'x',
}),
moduleRoutesOf,
),
'/settings',
);
});
test('모르는 · 꺼진 모듈이면 메일 첫 화면이다', () {
expect(
locationForPush(
PushTarget.fromData(<String, Object?>{
'module': 'calendar',
'id': 'e1',
}),
moduleRoutesOf,
),
homeLocation,
);
expect(PushTarget.fromData(<String, Object?>{'module': 'bogus'}), isNull);
expect(locationForPush(null, moduleRoutesOf), homeLocation);
});
});
}
@@ -0,0 +1,155 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/core/router/session_redirect.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
AsyncValue<SessionState> _signedIn([SessionRestriction? restriction]) {
return AsyncData<SessionState>(
SignedIn(
AuthSessionInfo(
email: 'ara.kim@arasystem.it.kr',
name: '김아라',
restriction: restriction,
),
),
);
}
void main() {
group('세션 상태로 화면 고르기', () {
test('되살리는 중이면 시작 화면으로 가고, 이미 있으면 그대로 둔다', () {
const AsyncValue<SessionState> loading = AsyncLoading<SessionState>();
expect(resolveSessionRedirect(loading, homeLocation), AppRoutes.start);
expect(resolveSessionRedirect(loading, AppRoutes.start), isNull);
});
test('되살리기가 장애로 실패해도 시작 화면이다([다시 시도] 자리)', () {
final AsyncValue<SessionState> failed = AsyncError<SessionState>(
Exception('offline'),
StackTrace.empty,
);
expect(resolveSessionRedirect(failed, AppRoutes.login), AppRoutes.start);
});
test('로그아웃이면 어디서든 로그인 화면으로 간다', () {
const AsyncValue<SessionState> signedOut = AsyncData<SessionState>(
SignedOut(),
);
expect(resolveSessionRedirect(signedOut, homeLocation), AppRoutes.login);
expect(
resolveSessionRedirect(signedOut, AppRoutes.loginCode),
AppRoutes.login,
);
expect(resolveSessionRedirect(signedOut, AppRoutes.login), isNull);
});
test('코드 단계면 코드 화면이다', () {
const AsyncValue<SessionState> code = AsyncData<SessionState>(
CodeRequired('ara.kim@arasystem.it.kr'),
);
expect(
resolveSessionRedirect(code, AppRoutes.login),
AppRoutes.loginCode,
);
expect(resolveSessionRedirect(code, AppRoutes.loginCode), isNull);
});
test('제한된 세션은 그 단계 화면에서 벗어나지 못한다', () {
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.passwordChange),
homeLocation,
),
AppRoutes.passwordChange,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.twoFactorSetup),
AppRoutes.passwordChange,
),
AppRoutes.twoFactorSetup,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.unknown),
homeLocation,
),
AppRoutes.restricted,
);
expect(
resolveSessionRedirect(
_signedIn(SessionRestriction.twoFactorSetup),
AppRoutes.twoFactorSetup,
),
isNull,
);
});
test('로그인했으면 로그인 전 화면에서 메일로 가고, 로그인 뒤 화면은 그대로 둔다', () {
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.start),
homeLocation,
);
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.login),
homeLocation,
);
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.passwordChange),
homeLocation,
);
expect(resolveSessionRedirect(_signedIn(), homeLocation), isNull);
expect(resolveSessionRedirect(_signedIn(), '/mail/inbox/m1'), isNull);
});
test('로그인했는데 모르는 모듈 · 꺼진 모듈의 주소면 메일 첫 화면으로 간다', () {
expect(resolveSessionRedirect(_signedIn(), '/calendar'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation);
expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull);
});
test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () {
final List<AppModule> mailOff = <AppModule>[
for (final AppModule m in kAppModules)
m.id == AppModuleId.mail
? AppModule(
id: m.id,
label: m.label,
icon: m.icon,
selectedIcon: m.selectedIcon,
isEnabled: false,
)
: m,
];
expect(
resolveSessionRedirect(_signedIn(), AppRoutes.login, modules: mailOff),
'/settings',
);
expect(
resolveSessionRedirect(_signedIn(), '/mail', modules: mailOff),
'/settings',
);
});
test('모르는 제한 이름은 제한이 있는 것(unknown)으로 읽는다', () {
expect(SessionRestriction.parse(null), isNull);
expect(SessionRestriction.parse(''), isNull);
expect(
SessionRestriction.parse('passwordChange'),
SessionRestriction.passwordChange,
);
expect(SessionRestriction.parse('newThing'), SessionRestriction.unknown);
expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown);
});
});
}
@@ -0,0 +1,140 @@
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
void main() {
group('첫 실행', () {
test('첫 실행 표시가 없으면 보안 저장소에 남은 토큰을 지우고 표시를 남긴다', () async {
final ApiHarness api = ApiHarness(firstRunMarked: false);
// 앱을 지웠다 다시 깔았다 — iOS Keychain 에는 옛 토큰이 남아 있다
api.secure.values[kRefreshTokenKey] = 'refresh-old';
await api.store.prepare();
expect(api.secure.values, isEmpty);
expect(api.secure.deleteAllCount, 1);
expect(api.firstRun.marked, isTrue);
expect(api.store.hasSession, isFalse);
});
test('첫 실행 표시가 있으면 남은 리프레시 토큰으로 세션이 있다고 본다', () async {
final ApiHarness api = ApiHarness();
api.secure.values[kRefreshTokenKey] = 'refresh-1';
await api.store.prepare();
expect(api.secure.deleteAllCount, 0);
expect(api.store.hasSession, isTrue);
// 액세스 토큰은 메모리에만 있으므로 새로 띄우면 없다
expect(api.store.accessToken, isNull);
expect(api.store.hasUsableAccessToken, isFalse);
});
test('두 번째 실행부터는 지우지 않는다', () async {
final ApiHarness api = ApiHarness(firstRunMarked: false);
await api.store.prepare();
api.secure.values[kRefreshTokenKey] = 'refresh-1';
await api.store.prepare();
expect(api.secure.values[kRefreshTokenKey], 'refresh-1');
expect(api.secure.deleteAllCount, 1);
});
});
group('보관', () {
late ApiHarness api;
setUp(() async {
api = ApiHarness();
await api.store.prepare();
});
test('리프레시 토큰은 보안 저장소에, 액세스 토큰은 메모리에만 둔다', () async {
await api.store.save(tokensFor(1));
expect(api.secure.values, <String, String>{
kRefreshTokenKey: 'refresh-1',
});
expect(api.store.accessToken, 'access-1');
expect(api.store.hasSession, isTrue);
expect(await api.store.readRefreshToken(), 'refresh-1');
});
test('저장 · 지우기마다 토큰 세대가 오른다', () async {
final int start = api.store.generation;
await api.store.save(tokensFor(1));
await api.store.clear();
expect(api.store.generation, start + 2);
expect(api.store.accessToken, isNull);
expect(api.store.hasSession, isFalse);
expect(api.secure.values, isEmpty);
});
test('액세스 토큰은 만료 30초 전부터 쓸 수 없는 것으로 본다', () async {
await api.store.save(tokensFor(1, now: api.clock.now));
api.clock.advance(const Duration(minutes: 59, seconds: 29));
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(seconds: 1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('남은 시간은 응답의 Date(서버 시각)로 잰다 — 기기 시계가 2시간 빨라도 쓸 수 있다', () async {
final DateTime serverNow = api.clock.now.subtract(
const Duration(hours: 2),
);
final AuthTokens tokens = AuthTokens.tryParse(
tokensJson(1, now: serverNow),
serverTime: serverNow,
)!;
await api.store.save(tokens);
// 기기 시계로는 이미 지난 만료 시각이지만 받은 때부터 1시간을 쓸 수 있다
expect(tokens.accessExpiresAt.isBefore(api.clock.now), isTrue);
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(minutes: 59, seconds: 29));
expect(api.store.hasUsableAccessToken, isTrue);
api.clock.advance(const Duration(seconds: 1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('기기 시계를 뒤로 돌리면 지난 시간을 모르니 미리 갱신하는 쪽으로 본다', () async {
await api.store.save(tokensFor(1, now: api.clock.now));
api.clock.advance(const Duration(minutes: -1));
expect(api.store.hasUsableAccessToken, isFalse);
});
test('사용자가 로그아웃한 것을 기억하고, 새 로그인이 지운다', () async {
await api.store.save(tokensFor(1));
await api.store.clear(byUser: true);
expect(api.store.signedOutByUser, isTrue);
await api.store.save(tokensFor(2));
expect(api.store.signedOutByUser, isFalse);
await api.store.clear();
expect(api.store.signedOutByUser, isFalse);
});
test('토큰 값은 toString 에 보이지 않는다', () {
expect(tokensFor(1).toString(), isNot(contains('access-1')));
expect(tokensFor(1).toString(), isNot(contains('refresh-1')));
});
test('모양이 틀린 tokens 칸은 읽지 않는다', () {
expect(AuthTokens.tryParse(null), isNull);
expect(
AuthTokens.tryParse(<String, Object?>{'accessToken': 'a'}),
isNull,
);
expect(AuthTokens.tryParse(tokensJson(3))?.refreshToken, 'refresh-3');
});
});
}
@@ -0,0 +1,57 @@
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_fonts.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:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('테마', () {
final ThemeData theme = AppTheme.light();
test('웹 토큰의 브랜드 색을 시드 없이 그대로 쓴다', () {
expect(theme.colorScheme.primary, AppColors.brand);
expect(theme.colorScheme.primaryContainer, AppColors.brandSubtle);
expect(theme.colorScheme.secondary, AppColors.brandSurface);
expect(theme.colorScheme.error, AppColors.danger);
expect(theme.colorScheme.onSurface, AppColors.text);
expect(theme.extension<AppExtraColors>()?.textMuted, AppColors.textMuted);
});
test('글꼴은 Pretendard, 본문은 15 기준이다', () {
expect(theme.textTheme.bodyMedium?.fontFamily, AppFonts.family);
expect(theme.textTheme.bodyMedium?.fontSize, AppTypography.bodyMedium);
expect(AppTypography.bodyMedium, 15);
});
test('단추의 누르는 곳은 48dp 이상이다', () {
expect(AppSize.touchMin, 48);
expect(
theme.filledButtonTheme.style?.minimumSize?.resolve(<WidgetState>{}),
const Size(AppSize.touchMin, AppSize.touchMin),
);
expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded);
});
});
test('Pretendard 의 OFL 1.1 라이선스를 라이선스 화면에 올린다', () async {
AppFonts.registerFontLicenses();
final List<LicenseEntry> entries = await LicenseRegistry.licenses.toList();
final Iterable<LicenseEntry> pretendard = entries.where(
(LicenseEntry entry) =>
entry.packages.contains(AppFonts.licensePackageName),
);
expect(pretendard, isNotEmpty);
final String text = pretendard.first.paragraphs
.map((LicenseParagraph p) => p.text)
.join('\n');
expect(text, contains('SIL OPEN FONT LICENSE Version 1.1'));
expect(text, contains('Reserved Font Name Pretendard'));
});
}
+7 -1
View File
@@ -19,7 +19,8 @@ void main() {
});
test('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () {
// dio_client 의 상태코드 역매핑과 세션 만료 승격이 이 코드들을 직접 사용한다.
// 상태 코드 역매핑(api_error_mapper) · 세션 만료 승격 · AUTH_010 다시 확인(auth_interceptor) ·
// 로그인 화면이 칸에 보이는 실패(auth_repository 의 kLoginInlineCodes)가 이 코드들을 직접 사용한다.
//
// ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서
// 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다.
@@ -30,9 +31,14 @@ void main() {
'AUTH_001',
'AUTH_002',
'AUTH_003',
'AUTH_004',
'AUTH_006',
'AUTH_007',
'AUTH_010',
'VAL_001',
'RES_001',
'BIZ_001',
'FILE_002',
];
for (final code in usedByClient) {
@@ -0,0 +1,663 @@
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/storage/token_store.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/provider_harness.dart';
void main() {
late ProviderHarness h;
late ProviderContainer container;
Future<ProviderContainer> start({String? refreshToken}) async {
await h.prepare(refreshToken: refreshToken);
container = h.container();
addTearDown(container.dispose);
// 세션을 듣기 시작해 되살리기를 돌린다
container.listen(sessionProvider, (_, _) {});
return container;
}
Future<SessionState?> settled() async {
try {
return await container.read(sessionProvider.future);
} on Object {
return null;
}
}
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;
}
void refreshGives(int n, {String? restriction}) {
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
}),
);
}
/// 코드 단계로 답하는 로그인
FakeResponse codeStep() => FakeResponse.ok(<String, Object?>{
'status': 'twoFactorRequired',
'email': null,
'name': null,
'restriction': null,
'twoFactorDeadline': null,
'tokens': null,
});
/// 로그인을 마친 응답 ([n] 번째 토큰)
FakeResponse signedInWith(int n, {String? restriction}) =>
FakeResponse.ok(<String, Object?>{
'status': 'signedIn',
...sessionJson(restriction: restriction),
'tokens': tokensJson(n, now: h.clock.now),
});
List<RequestOptions> logouts() =>
h.server.received('POST', ApiPaths.appLogout);
setUp(() => h = ProviderHarness());
group('되살리기', () {
test('리프레시 토큰이 없으면 서버에 묻지 않고 로그아웃 상태다', () async {
await start();
expect(await settled(), const SignedOut());
expect(h.server.requests, isEmpty);
});
test('리프레시 토큰이 있으면 갱신 뒤 me 로 세션을 되살린다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
);
final SessionState? state = await settled();
expect(state, isA<SignedIn>());
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(
bearerTokenOf(h.server.received('GET', ApiPaths.me).single),
'access-2',
);
// 되살리기는 알리지 않는다
expect(h.notifier.shownCodes, isEmpty);
});
test('갱신이 거절되면 토큰을 지우고 알림 없이 로그아웃 상태로 시작한다', () async {
await start(refreshToken: 'refresh-1');
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await settled(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
test('연결이 끊겨 되살리지 못하면 세션을 두고 오류 상태다 — [다시 시도]는 곧바로 다시 묻는다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
expect(await settled(), isNull);
expect(container.read(sessionProvider).hasError, isTrue);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
// 5초 안에 눌러도 방금의 실패를 다시 쓰지 않는다
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
notifier().retry();
expect(await settled(), isA<SignedIn>());
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(2));
});
});
group('로그인', () {
test('2단계 인증을 켠 직원은 코드 단계로 가고, 코드와 비밀번호를 함께 보내 로그인한다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) {
final Map<String, Object?> body = options.data as Map<String, Object?>;
return body['otpCode'] == null ? codeStep() : signedInWith(1);
});
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw-1234',
);
expect(current(), const CodeRequired('ara.kim@arasystem.it.kr'));
await notifier().submitCode('123456');
expect(current(), isA<SignedIn>());
expect(
h.server.received('POST', ApiPaths.appLogin).last.data,
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'password': 'pw-1234',
'otpCode': '123456',
},
);
expect(h.secure.values[kRefreshTokenKey], 'refresh-1');
expect(h.store.accessToken, 'access-1');
// 제한 없이 로그인을 마치면 쥐고 있던 비밀번호를 지운다
expect(notifier().heldLogin, isNull);
});
test('코드 단계에 10분 넘게 머물면 보내지 않고 첫 단계로 돌아간다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => codeStep());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
expect(notifier().heldLogin?.password, 'pw');
h.clock.advance(const Duration(minutes: 10));
await notifier().submitCode('123456');
expect(current(), const SignedOut(reason: SignOutReason.codeTimedOut));
expect(h.server.received('POST', ApiPaths.appLogin), hasLength(1));
});
test('제한된 세션으로 로그인하면 새 비밀번호 단계가 쓸 비밀번호를 쥐고 있는다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(restrictionNow(), SessionRestriction.passwordChange);
expect(notifier().heldLogin?.password, 'temp-pw');
});
test('로그인 화면이 칸에 보이는 실패(AUTH_004)는 알리지 않고 코드로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.fail(401, 'AUTH_004'),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'wrong'),
);
expect(apiErrorOf(error).code, 'AUTH_004');
expect(h.notifier.shownCodes, isEmpty);
expect(current(), const SignedOut());
});
test('응답 모양이 계약과 다르면 DioException(SYS_001)으로 던진다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => FakeResponse.ok(<String, Object?>{'status': 'signedIn'}),
);
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.store.hasSession, isFalse);
});
});
group('늦게 온 결과 버리기 (시도 세대)', () {
test('[다른 계정으로 로그인] 뒤에 온 코드 단계의 로그인 결과는 쓰지 않고 그 세션을 조용히 끝낸다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (RequestOptions options) async {
final Map<String, Object?> body = options.data as Map<String, Object?>;
if (body['otpCode'] == null) return codeStep();
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
final Future<void> code = notifier().submitCode('123456');
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).length == 2,
reason: '코드 요청이 서버에 닿기',
);
await notifier().switchAccount();
release.complete();
await code;
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('로그아웃 뒤에 온 로그인 결과도 버린다', () async {
await start();
await settled();
final Completer<void> release = Completer<void>();
h.server.on('POST', ApiPaths.appLogin, (_) async {
await release.future;
return signedInWith(1);
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.server.received('POST', ApiPaths.appLogin).isNotEmpty,
reason: '로그인 요청이 서버에 닿기',
);
await notifier().signOut();
release.complete();
await signIn;
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '조용한 로그아웃');
expect(bearerTokenOf(logouts().single), 'access-1');
});
test('제한을 다시 맞추는 사이 로그아웃하면 결과를 쓰지 않는다(stale)', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
final Completer<void> release = Completer<void>();
int meCalls = 0;
h.server.on('GET', ApiPaths.me, (_) async {
meCalls++;
if (meCalls == 1) return FakeResponse.ok(sessionJson());
await release.future;
return FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup'));
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
final Future<RestrictionCheck> check = notifier().recheck();
await pumpEventsUntil(() => meCalls == 2, reason: '세션 확인이 서버에 닿기');
await notifier().signOut();
release.complete();
expect(await check, RestrictionCheck.stale);
expect(current(), const SignedOut());
});
});
group('로그인한 뒤', () {
Future<void> signedIn({String? restriction}) async {
await start(refreshToken: 'refresh-1');
refreshGives(2, restriction: restriction);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: restriction)),
);
expect(await settled(), isA<SignedIn>());
}
test('갱신이 거절되면(다른 곳에서 끊음) 로그인 화면으로 간다', () 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(<Object?>[]));
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝 알림');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.notifier.shownCodes, <String>['AUTH_002']);
});
test(
'AUTH_010 — me 에 제한이 있으면 그 단계로 옮기고 원래 요청은 다시 보내지 않으며 알리지 않는다',
() async {
await signedIn();
h.server.on(
'GET',
'/mailboxes',
(_) => FakeResponse.fail(403, 'AUTH_010'),
);
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.ok(sessionJson(restriction: 'twoFactorSetup')),
);
final Object error = await errorOf(
container.read(dioProvider).get<Object?>('/mailboxes'),
);
expect(apiErrorOf(error).code, 'AUTH_010');
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
expect(h.server.received('GET', '/mailboxes'), hasLength(1));
// 제한이 있으면 갱신하지 않는다 — 되살리기 때의 한 번뿐
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1));
expect(h.notifier.shownCodes, isEmpty);
},
);
test('AUTH_010 — me 에 제한이 없으면(옛 토큰) 갱신한 뒤 원래 요청을 한 번 다시 보낸다', () async {
await signedIn();
int mailboxCalls = 0;
h.server.on('GET', '/mailboxes', (_) {
mailboxCalls++;
return mailboxCalls == 1
? FakeResponse.fail(403, 'AUTH_010')
: FakeResponse.ok(<Object?>['inbox']);
});
refreshGives(3);
final Response<Object?> response = await container
.read(dioProvider)
.get<Object?>('/mailboxes');
expect(response.data, <Object?>['inbox']);
final List<RequestOptions> sent = h.server.received('GET', '/mailboxes');
expect(sent.map(bearerTokenOf), <String?>['access-2', 'access-3']);
expect(h.notifier.shownCodes, isEmpty);
expect(current(), isA<SignedIn>());
});
test('갱신 응답의 제한이 바뀌면 화면 상태도 바꾼다', () async {
await signedIn();
h.clock.advance(const Duration(hours: 2));
refreshGives(3, restriction: 'twoFactorSetup');
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
await container.read(dioProvider).get<Object?>('/mailboxes');
await pumpEventsUntil(
() => restrictionNow() == SessionRestriction.twoFactorSetup,
reason: '갱신 응답의 제한 반영',
);
expect(restrictionNow(), SessionRestriction.twoFactorSetup);
});
test('제한이 풀리면 쥐고 있던 비밀번호를 바로 지운다', () async {
await start();
await settled();
h.server.on(
'POST',
ApiPaths.appLogin,
(_) => signedInWith(1, restriction: 'passwordChange'),
);
await notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'temp-pw',
);
expect(notifier().heldLogin, isNotNull);
// 새 비밀번호를 정한 뒤 곧바로 갱신 — 응답에 제한이 없다
refreshGives(2);
await container.read(authRepositoryProvider).refresh();
await pumpEventsUntil(
() => restrictionNow() == null && current() is SignedIn,
reason: '제한이 풀린 갱신 반영',
);
expect(notifier().heldLogin, isNull);
});
test('로그아웃하면 토큰을 지우고 서버 세션을 리프레시 토큰과 Bearer 로 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(h.store.accessToken, isNull);
expect(h.store.signedOutByUser, isTrue);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-2',
});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('보안 저장소를 읽지 못해도 이 기기의 토큰은 지우고 Bearer 로 서버 세션을 끝낸다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failReads = true;
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.accessToken, isNull);
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{});
expect(bearerTokenOf(logouts().single), 'access-2');
});
test('로그아웃 요청이 닿지 않아도 이 기기는 로그아웃 상태다', () async {
await signedIn();
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.offline);
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.notifier.shownCodes, isEmpty);
});
});
group('제한 다시 맞추기 — 노티파이어의 갈래', () {
Future<void> signedIn() async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
expect(await settled(), isA<SignedIn>());
}
int refreshCalls() => h.server.received('POST', ApiPaths.appRefresh).length;
test('me 가 401(액세스 토큰이 끝남)이면 갱신해 보고, 새 토큰에 제한이 없으면 renewed', () async {
await signedIn();
h.server.on('GET', ApiPaths.me, (RequestOptions options) {
return bearerTokenOf(options) == 'access-2'
? FakeResponse.fail(401, 'AUTH_001')
: FakeResponse.ok(sessionJson());
});
refreshGives(3);
expect(await notifier().recheck(), RestrictionCheck.renewed);
expect(refreshCalls(), 2);
expect(h.store.accessToken, 'access-3');
expect(current(), isA<SignedIn>());
expect(restrictionNow(), isNull);
});
test('me 401 뒤 갱신도 401 이면 stale — 세션 끝이 로그인 화면으로 옮긴다', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.fail(401, 'AUTH_001'),
);
expect(await notifier().recheck(), RestrictionCheck.stale);
await pumpEventsUntil(() => current() is SignedOut, reason: '세션 끝');
expect(current(), const SignedOut(reason: SignOutReason.expired));
expect(h.store.hasSession, isFalse);
});
test('me 가 장애(503)면 unknown — 상태와 토큰은 그대로', () async {
await signedIn();
h.server.on(
'GET',
ApiPaths.me,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
expect(await notifier().recheck(), RestrictionCheck.unknown);
expect(current(), isA<SignedIn>());
expect(refreshCalls(), 1);
expect(h.store.hasSession, isTrue);
});
});
group('저장 · 로그아웃의 경합', () {
test('로그인 토큰을 저장하는 사이 로그아웃하면 토큰이 남지 않고 그 세션도 끝낸다', () async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
final Completer<void> gate = Completer<void>();
h.secure.writeGate = gate;
final Future<void> signIn = notifier().signIn(
email: 'ara.kim@arasystem.it.kr',
password: 'pw',
);
await pumpEventsUntil(
() => h.secure.writeCalls == 1,
reason: '토큰 쓰기가 시작',
);
final Future<void> signOut = notifier().signOut();
gate.complete();
await signOut;
await signIn;
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(
logouts().every(
(RequestOptions r) =>
(r.data! as Map<String, Object?>)['refreshToken'] == 'refresh-1',
),
isTrue,
);
});
test(
'로그인 토큰을 보안 저장소에 두지 못하면 이 기기의 토큰을 지우고 그 세션을 끝낸 뒤 SYS_001 로 던진다',
() async {
await start();
await settled();
h.server.on('POST', ApiPaths.appLogin, (_) => signedInWith(1));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
h.secure.failWrites = true;
final Object error = await errorOf(
notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw'),
);
expect(error, isA<DioException>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.store.accessToken, isNull);
await pumpEventsUntil(() => logouts().isNotEmpty, reason: '서버 세션 끝내기');
expect(bearerTokenOf(logouts().single), 'access-1');
},
);
test('로그아웃은 토큰과 상태를 먼저 지우고, 알림 토큰 지우기를 기다리지 않는다', () async {
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isA<SignedIn>());
// 알림 토큰 지우기가 끝나지 않아도 로그아웃은 끝난다(짧은 한도 · 실패는 삼킨다)
h.push.unregisterGate = Completer<void>();
await notifier().signOut();
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
expect(h.push.unregistered, 1);
expect(h.push.hadSessionAtUnregister, isFalse);
expect(logouts(), hasLength(1));
});
test('되살리기가 장애로 멈춘 채 [다른 계정으로 로그인]을 눌러도 남은 리프레시 토큰을 지운다', () async {
await start(refreshToken: 'refresh-1');
h.server.on('POST', ApiPaths.appRefresh, (_) => FakeResponse.offline);
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
expect(await settled(), isNull);
await notifier().switchAccount();
expect(current(), const SignedOut(reason: SignOutReason.switchAccount));
expect(h.store.hasSession, isFalse);
expect(h.secure.values.containsKey(kRefreshTokenKey), isFalse);
expect(logouts().single.data, <String, Object?>{
'refreshToken': 'refresh-1',
});
});
test('되살리는 사이 로그아웃하면 늦게 온 되살리기 결과가 로그아웃을 덮지 않는다', () async {
final Completer<void> release = Completer<void>();
await start(refreshToken: 'refresh-1');
refreshGives(2);
h.server.on('GET', ApiPaths.me, (_) async {
await release.future;
return FakeResponse.ok(sessionJson());
});
h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
await pumpEventsUntil(
() => h.server.received('GET', ApiPaths.me).isNotEmpty,
reason: '되살리기의 me 가 서버에 닿기',
);
await notifier().signOut();
release.complete();
expect(await settled(), const SignedOut());
expect(current(), const SignedOut());
expect(h.store.hasSession, isFalse);
});
});
}
+358
View File
@@ -0,0 +1,358 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_client.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/storage/auth_tokens.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';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 시험의 기준 시각 (UTC)
final DateTime kTestNow = DateTime.utc(2026, 11, 5, 3);
/// 시험의 User-Agent
const String kTestUserAgent = 'ARASYSTEM-App/1.0.0 (Android 15; Pixel 8)';
/// 손으로 돌리는 시계 — 고정 대기 없이 시간을 넘긴다
class ManualClock {
ManualClock([DateTime? start]) : now = start ?? kTestNow;
DateTime now;
DateTime call() => now;
void advance(Duration duration) => now = now.add(duration);
}
/// 메모리 보안 저장소 — [failReads] · [failWrites] · [failDeletes] 를 켜면 그 일을 `PlatformException` 으로 실패한다.
/// [readGate] · [writeGate] 를 두면 그 일이 그 Completer 가 끝날 때까지 기다린다(경합 시험).
class MemorySecureStore implements SecureStore {
final Map<String, String> values = <String, String>{};
int deleteAllCount = 0;
int writeCount = 0;
/// 쓰기를 부른 수(문을 기다리기 전에 센다)
int writeCalls = 0;
/// 읽기에서 던질 뜻밖의 예외(`PlatformException` 이 아닌 것) — 인터셉터의 처리기 지키기 시험
Object? throwOnRead;
bool failReads = false;
bool failWrites = false;
bool failDeletes = false;
Completer<void>? readGate;
Completer<void>? writeGate;
static PlatformException _failure(String what) =>
PlatformException(code: 'test', message: '$what 실패(시험)');
/// 읽기를 부른 수(문을 기다리기 전에 센다)
int readCalls = 0;
@override
Future<String?> read(String key) async {
readCalls++;
await readGate?.future;
final Object? unexpected = throwOnRead;
if (unexpected != null) throw unexpected;
if (failReads) throw _failure('읽기');
return values[key];
}
@override
Future<void> write(String key, String value) async {
writeCalls++;
await writeGate?.future;
writeCount++;
if (failWrites) throw _failure('쓰기');
values[key] = value;
}
@override
Future<void> delete(String key) async {
if (failDeletes) throw _failure('지우기');
values.remove(key);
}
@override
Future<void> deleteAll() async {
deleteAllCount++;
values.clear();
}
}
/// 메모리 첫 실행 표시
class MemoryFirstRunMarker implements FirstRunMarker {
MemoryFirstRunMarker({this.marked = true});
bool marked;
@override
Future<bool> isMarked() async => marked;
@override
Future<void> mark() async => marked = true;
}
/// 푸시 기기 등록을 적어 두는 가짜 — 지울 때 세션이 남아 있었는지도 적는다
class RecordingPushRegistrar implements PushRegistrar {
RecordingPushRegistrar(this._store);
final TokenStore _store;
int registered = 0;
int unregistered = 0;
bool? hadSessionAtUnregister;
/// 두면 지우기가 이것이 끝날 때까지 기다린다
Completer<void>? unregisterGate;
@override
Future<void> register() async => registered++;
@override
Future<void> unregister() async {
unregistered++;
hadSessionAtUnregister = _store.hasSession;
await unregisterGate?.future;
}
}
/// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다
class RecordingNotifier extends ErrorNotifier {
RecordingNotifier(ManualClock clock)
: super(
messengerKey: GlobalKey<ScaffoldMessengerState>(),
clock: clock.call,
);
final List<String> shownCodes = <String>[];
@override
void display(ApiException error) => shownCodes.add(error.code);
}
/// 가짜 응답
class FakeResponse {
const FakeResponse(this.status, this.body, {this.rawBody});
/// 표준 성공 `{ success: true, data }`
factory FakeResponse.ok([Object? data]) =>
FakeResponse(200, <String, Object?>{'success': true, 'data': data});
/// 표준 실패 `{ success: false, error: { code, message, traceId } }`
factory FakeResponse.fail(int status, String code) =>
FakeResponse(status, <String, Object?>{
'success': false,
'error': <String, Object?>{
'code': code,
'message': '',
'traceId': 'trace-$code',
},
});
/// 응답 없음(연결 끊김)
static const FakeResponse offline = FakeResponse(-1, null);
final int status;
final Object? body;
/// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신)
final String? rawBody;
}
typedef FakeHandler = FutureOr<FakeResponse> Function(RequestOptions options);
/// 가짜 서버 — 경로마다 처리기를 둔다 (HttpClientAdapter)
class FakeServer implements HttpClientAdapter {
final Map<String, FakeHandler> _routes = <String, FakeHandler>{};
/// 받은 요청 차례대로
final List<RequestOptions> requests = <RequestOptions>[];
/// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다
DateTime? serverTime;
void on(String method, String path, FakeHandler handler) {
_routes['$method $path'] = handler;
}
/// [method] [path] 로 받은 요청들
List<RequestOptions> received(String method, String path) => requests
.where((RequestOptions r) => r.method == method && r.path == path)
.toList();
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
// 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게
requests.add(options.copyWith());
final FakeHandler? handler = _routes['${options.method} ${options.path}'];
final FakeResponse response = handler == null
? FakeResponse.fail(404, 'RES_001')
: await handler(options);
if (response.status < 0) {
throw DioException.connectionError(
requestOptions: options,
reason: 'offline',
);
}
final DateTime? date = serverTime;
return ResponseBody.fromString(
response.rawBody ?? jsonEncode(response.body),
response.status,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
if (date != null)
HttpHeaders.dateHeader: <String>[HttpDate.format(date)],
},
);
}
@override
void close({bool force = false}) {}
}
/// 요청의 Bearer 토큰 (없으면 null)
String? bearerTokenOf(RequestOptions options) {
final Object? header = options.headers['Authorization'];
if (header is! String || !header.startsWith('Bearer ')) return null;
return header.substring('Bearer '.length);
}
/// 토큰 한 벌 — [n] 번째 발급
AuthTokens tokensFor(
int n, {
DateTime? now,
Duration accessLife = const Duration(hours: 1),
}) {
final DateTime base = now ?? kTestNow;
return AuthTokens(
accessToken: 'access-$n',
accessExpiresAt: base.add(accessLife),
refreshToken: 'refresh-$n',
refreshExpiresAt: base.add(const Duration(days: 30)),
);
}
/// 응답의 `tokens` 칸 꼴
Map<String, Object?> tokensJson(int n, {DateTime? now}) {
final AuthTokens tokens = tokensFor(n, now: now);
return <String, Object?>{
'accessToken': tokens.accessToken,
'accessExpiresAt': tokens.accessExpiresAt.toIso8601String(),
'refreshToken': tokens.refreshToken,
'refreshExpiresAt': tokens.refreshExpiresAt.toIso8601String(),
};
}
/// 직원 · 세션 칸
Map<String, Object?> sessionJson({String? restriction, String? deadline}) =>
<String, Object?>{
'email': 'ara.kim@arasystem.it.kr',
'name': '김아라',
'restriction': restriction,
'twoFactorDeadline': deadline,
};
/// 네트워크 층을 한데 묶은 시험대
class ApiHarness {
ApiHarness({bool firstRunMarked = true})
: firstRun = MemoryFirstRunMarker(marked: firstRunMarked);
final FakeServer server = FakeServer();
final MemorySecureStore secure = MemorySecureStore();
final MemoryFirstRunMarker firstRun;
final ManualClock clock = ManualClock();
final SessionEventBus events = SessionEventBus();
final List<SessionEvent> emitted = <SessionEvent>[];
/// `AUTH_010` 을 다시 맞춰 달라는 알림에 이 시험대가 돌려줄 결과 (세션 노티파이어 대신)
RestrictionCheck recheckAnswer = RestrictionCheck.unknown;
/// 있으면 [recheckAnswer] 대신 이것으로 답한다 — 노티파이어처럼 새 토큰을 받아 두고 답하는 흉내
Future<RestrictionCheck> Function()? onRecheck;
/// 제한 다시 맞추기를 몇 번 물었나
int get recheckCount => emitted.whereType<SessionRecheckNeeded>().length;
late final TokenStore store = TokenStore(
secure: secure,
firstRun: firstRun,
clock: clock.call,
);
late final RecordingNotifier notifier = RecordingNotifier(clock);
late final DioClient client = DioClient.create(
config: const ApiConfig(baseUrl: 'https://portal.test/api'),
tokenStore: store,
events: events,
notifier: notifier,
userAgent: kTestUserAgent,
clock: clock.call,
httpClientAdapter: server,
);
Dio get dio => client.dio;
/// 준비 — 첫 실행 처리 · 세션 알림 적기(제한 다시 맞추기에는 [recheckAnswer] 로 답한다)
Future<void> prepare() async {
events.stream.listen((SessionEvent event) async {
emitted.add(event);
if (event is! SessionRecheckNeeded) return;
final Future<RestrictionCheck> Function()? answer = onRecheck;
event.reply(answer == null ? recheckAnswer : await answer());
});
await store.prepare();
}
/// 로그인한 상태로 만든다 ([n] 번째 토큰)
Future<void> signIn([int n = 1]) => store.save(tokensFor(n, now: clock.now));
/// 앱을 새로 띄운 상태 — 보안 저장소에 리프레시 토큰만 있다
Future<void> coldStartWithRefreshToken(String refreshToken) async {
secure.values[kRefreshTokenKey] = refreshToken;
await store.prepare();
}
}
/// 실패에서 ApiException 을 꺼낸다
ApiException apiErrorOf(Object error) {
if (error is DioException && error.error is ApiException) {
return error.error! as ApiException;
}
if (error is ApiException) return error;
throw StateError('ApiException 이 아니다: $error');
}
/// [condition] 이 될 때까지 이벤트 차례를 넘긴다 — 고정 대기 없이, [maxTurns] 를 넘으면 실패한다
Future<void> pumpEventsUntil(
bool Function() condition, {
int maxTurns = 1000,
String reason = '조건',
}) async {
for (int turn = 0; turn < maxTurns; turn++) {
if (condition()) return;
await Future<void>.delayed(Duration.zero);
}
if (!condition()) throw StateError('$reason 이 $maxTurns 차례 안에 되지 않았다');
}
/// 미래가 실패하면 그 오류를 돌려준다(성공하면 시험 실패)
Future<Object> errorOf(Future<Object?> future) async {
try {
await future;
} on Object catch (error) {
return error;
}
throw StateError('실패해야 하는데 성공했다');
}
@@ -0,0 +1,47 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/push/push_registration.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/provider_retry.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'fakes.dart';
/// 프로바이더 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계로 앱의 프로바이더를 띄운다
class ProviderHarness {
ProviderHarness();
final FakeServer server = FakeServer();
final MemorySecureStore secure = MemorySecureStore();
final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker();
final ManualClock clock = ManualClock();
late final TokenStore store = TokenStore(
secure: secure,
firstRun: firstRun,
clock: clock.call,
);
late final RecordingNotifier notifier = RecordingNotifier(clock);
late final RecordingPushRegistrar push = RecordingPushRegistrar(store);
/// ProviderScope · ProviderContainer 에 넣을 덮어쓰기
List<Override> get overrides => <Override>[
tokenStoreProvider.overrideWithValue(store),
httpClientAdapterProvider.overrideWithValue(server),
clockProvider.overrideWithValue(clock.call),
errorNotifierProvider.overrideWithValue(notifier),
userAgentProvider.overrideWithValue(kTestUserAgent),
pushRegistrarProvider.overrideWithValue(push),
];
/// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너
ProviderContainer container() =>
ProviderContainer(overrides: overrides, retry: noAutomaticRetry);
/// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다
Future<void> prepare({String? refreshToken}) async {
if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken;
await store.prepare();
}
}