generated from ttipo/project-base
feat: 앱 기반과 앱 서버 — 앱 전용 로그인 · 새 메일 알림(FCM) · 앱 설정 #20
+63
-11
@@ -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.
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 = '로그인 제한';
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 로 덮어써야 한다',
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
// Empty directory placeholder
|
||||
@@ -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
@@ -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 +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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user