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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 11:11:28 +09:00
parent 5823f600aa
commit 35fcf5e4ef
82 changed files with 7339 additions and 324 deletions
+31
View File
@@ -0,0 +1,31 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/router/app_router.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱이 쓰는 말 — 한국어만 (복사 · 붙여 넣기 메뉴 · 날짜 고르기 같은 시스템 글도 한국어)
const Locale kAppLocale = Locale('ko');
/// 앱 뿌리 — 테마 · 한국어 · 라우터 · 전역 알림 자리
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(
title: kAppName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
locale: kAppLocale,
supportedLocales: const <Locale>[kAppLocale],
// Material · Cupertino · Widgets 셋 — flutter_localizations
localizationsDelegates: GlobalMaterialLocalizations.delegates,
// API 실패 알림이 이 키 하나로 뜬다 (core/api/error_notifier.dart)
scaffoldMessengerKey: ref.watch(scaffoldMessengerKeyProvider),
routerConfig: ref.watch(routerProvider),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// `.env` 의 API 주소 키 (`flutter/.env.example`)
const String kApiUrlEnvKey = 'API_URL';
/// `.env` 가 없을 때의 주소 — 백엔드 글로벌 prefix `/api` 를 포함한다.
/// Android 에뮬레이터에서는 `.env` 에 `http://10.0.2.2:3000/api` 를 넣는다.
const String kDefaultApiUrl = 'http://localhost:3000/api';
/// 연결 · 받기 한도
const Duration kApiConnectTimeout = Duration(seconds: 15);
const Duration kApiReceiveTimeout = Duration(seconds: 15);
/// API 연결 설정
class ApiConfig {
const ApiConfig({
required this.baseUrl,
this.connectTimeout = kApiConnectTimeout,
this.receiveTimeout = kApiReceiveTimeout,
});
/// `.env` 를 읽어 만든다 (main 에서 dotenv 를 먼저 읽는다 — 없으면 기본 주소)
factory ApiConfig.fromEnv() {
final String? fromEnv = dotenv.isInitialized
? dotenv.maybeGet(kApiUrlEnvKey)
: null;
final String baseUrl = (fromEnv == null || fromEnv.isEmpty)
? kDefaultApiUrl
: fromEnv;
return ApiConfig(baseUrl: baseUrl);
}
final String baseUrl;
final Duration connectTimeout;
final Duration receiveTimeout;
}
@@ -0,0 +1,91 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
/// 실패를 에러 코드가 정해진 [ApiException] 으로 바꾼다 (에러 코드 규칙 "받는 방법").
///
/// - 표준 실패 본문(`{ success: false, error: { code, traceId } }`)이 있으면 그 코드.
/// nginx 가 직접 끝낸 실패도 이 모양이다(`message` 는 비어 올 수 있다 — 문구는 사전에서만 온다).
/// - 본문이 없으면 HTTP 상태로 거꾸로 맞춘다. 응답이 아예 없으면(연결 끊김 · 시간 초과) `SYS_001`.
/// - 세션 만료 승격(`AUTH_002`)은 여기서 하지 않는다 — 갱신을 해 본 인증 인터셉터만 정한다.
ApiException toApiException(DioException error) {
final Object? existing = error.error;
if (existing is ApiException) return existing;
final Response<Object?>? response = error.response;
if (response == null) return ApiException.fromCode(kFallbackCode);
final ({String? code, String? traceId}) body = readErrorBody(response.data);
return ApiException.fromCode(
body.code ?? codeForStatus(response.statusCode),
traceId: body.traceId,
statusCode: response.statusCode,
);
}
/// 마지막 폴백 코드 — 사전의 폴백과 같다
const String kFallbackCode = 'SYS_001';
/// HTTP 상태 → 에러 코드 (서버가 표준 실패 본문을 주지 못한 경우)
String codeForStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 413:
return 'FILE_002';
case 429:
return 'REQ_001';
default:
return kFallbackCode;
}
}
/// 표준 실패 본문에서 코드 · 추적 ID 만 꺼낸다
({String? code, String? traceId}) readErrorBody(Object? payload) {
if (payload is! Map<String, Object?>) return (code: null, traceId: null);
final Object? error = payload['error'];
if (error is! Map<String, Object?>) return (code: null, traceId: null);
final Object? code = error['code'];
final Object? traceId = error['traceId'];
return (
code: code is String && code.isNotEmpty ? code : null,
traceId: traceId is String && traceId.isNotEmpty ? traceId : null,
);
}
/// 실패에서 에러 코드만 — 화면 · 노티파이어가 `code` 로 가를 때
String? apiErrorCodeOf(Object error) {
if (error is ApiException) return error.code;
if (error is DioException) return toApiException(error).code;
return null;
}
/// 앱이 스스로 정한 실패도 같은 꼴(`DioException` · `error` 에 [ApiException])로 만든다.
///
/// 저장소 · 세션 노티파이어의 실패는 늘 이 꼴이다 — 응답 모양이 계약과 다르거나(서버 쪽 문제)
/// 받은 토큰을 보안 저장소에 두지 못한 경우도. 화면은 `on DioException` 하나로 받아 `apiErrorCodeOf` 로 가른다.
DioException apiFailure(
RequestOptions requestOptions,
String code, {
Response<Object?>? response,
}) {
final ({String? code, String? traceId}) body = readErrorBody(response?.data);
return DioException(
requestOptions: requestOptions,
response: response,
type: response == null
? DioExceptionType.unknown
: DioExceptionType.badResponse,
error: ApiException.fromCode(
code,
traceId: body.traceId,
statusCode: response?.statusCode,
),
);
}
+17
View File
@@ -0,0 +1,17 @@
/// API 경로 — baseUrl(`…/api`) 뒤에 붙는다. 경로는 대소문자를 가린다(계약 머리말).
abstract final class ApiPaths {
// ── 앱 로그인 (계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — 서버는 아직 없다) ──
static const String appLogin = '/auth/app/login';
static const String appRefresh = '/auth/app/refresh';
static const String appLogout = '/auth/app/logout';
/// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다
static const String me = '/auth/me';
/// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일")
static const Set<String> noRefresh = <String>{
appLogin,
appRefresh,
appLogout,
};
}
+371
View File
@@ -0,0 +1,371 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
/// 세션 제한 코드 — 제한된 세션으로 허용하지 않은 API 를 불렀다 (계약 6절)
const String kRestrictedSessionCode = 'AUTH_010';
/// 확인하지 못한(unknown) 제한 다시 맞추기를 같은 토큰 세대에서 다시 쓰는 시간 — 장애 동안 줄 선 `AUTH_010` 마다 묻지 않게
const Duration kRestrictionCheckReuse = Duration(seconds: 5);
/// 인증 인터셉터 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일".
///
/// - Bearer 를 붙인다. 액세스 토큰이 없거나 곧 끝나면 보내기 전에 갱신한다.
/// - 401 이면 갱신 한 번 뒤 원래 요청을 한 번 다시 보낸다(앱 login · refresh · logout 은 제외).
/// - 403 `AUTH_010` 이면 세션 노티파이어에 제한을 다시 맞춰 달라고 하고, 옛 토큰이었으면(`renewed`) 한 번 다시 보낸다.
/// 제한을 확인했거나 세션이 바뀌었으면 알리지 않고, 확인하지 못했거나 다시 보낸 요청도 막히면 알린다(웹 `recheckRestriction` 과 같은 결).
/// 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다 — 그사이 새 토큰을 받았으면 묻지 않고 다시 보내고, 제한을 확인한 결과는 그 세대 동안 다시 쓴다.
/// - **다시 보낸 요청의 실패도 첫 요청과 같은 규칙을 탄다**(웹은 다시 보낸 요청이 인터셉터를 다시 지난다 — 401 · `AUTH_010` 마다 한 번씩만 다시 보낸다).
/// - **그 요청을 보낸 세션이 끝났으면**(로그아웃 · 세션 끝 — 세션 세대가 다르다) 어떤 실패든 조용히 끝낸다.
/// 다른 직원이 그사이 로그인했어도 그 토큰으로 다시 보내지 않는다. 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`.
///
/// `QueuedInterceptor` 라 요청 · 오류를 차례로 다룬다. 갱신 · 세션 확인 · 다시 보내기는 이 인터셉터가 없는 [_retryDio] 로 한다 —
/// 같은 Dio 로 보내면 그 요청의 오류가 지금 처리 중인 오류 뒤에 줄을 서서 서로 기다리게 된다.
/// 처리기(handler)는 뜻밖의 예외에도 꼭 한 번 부른다 — 부르지 않으면 Dio 의 줄이 영구히 선다.
/// 알림을 건너뛸 실패는 `handler.reject`, 알릴 실패는 `handler.next` 로 끝내고, 내보내기 전에 토큰 · 비밀 본문을 지운다([withoutSecrets]).
class AuthInterceptor extends QueuedInterceptor {
AuthInterceptor({
required TokenStore store,
required TokenRefresher refresher,
required Dio retryDio,
required SessionEventBus events,
Clock clock = systemClock,
}) : _store = store,
_refresher = refresher,
_retryDio = retryDio,
_events = events,
_clock = clock;
final TokenStore _store;
final TokenRefresher _refresher;
final Dio _retryDio;
final SessionEventBus _events;
final Clock _clock;
({int generation, RestrictionCheck check, DateTime at})? _lastCheck;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
DioException? failure;
bool notify = true;
try {
if (!_isNoRefreshPath(options)) {
// 액세스 토큰이 없거나(앱을 새로 띄움) 곧 끝나면 보내기 전에 갱신한다 — 401 을 한 번 덜 본다
if (_store.hasSession && !_store.hasUsableAccessToken) {
final RefreshOutcome outcome = await _refresher.refresh(
sentGeneration: _store.generation,
);
final ApiException? refreshFailure = _failureOf(outcome);
if (refreshFailure != null) {
failure = _rejectedBeforeSend(options, refreshFailure);
// 그사이 로그아웃했으면(Discarded) 알림 인터셉터를 건너뛴다
notify = outcome is! RefreshDiscarded;
}
}
if (failure == null) _attach(options);
}
} on Object catch (error, stackTrace) {
failure = _unexpected(options, error, stackTrace);
}
if (failure == null) {
handler.next(options);
} else {
handler.reject(withoutSecrets(failure), notify);
}
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
_Outcome outcome;
try {
outcome = await _settle(err);
} on Object catch (error, stackTrace) {
outcome = _Notify(_unexpected(err.requestOptions, error, stackTrace));
}
switch (outcome) {
case _Resolve(:final Response<Object?> response):
handler.resolve(response);
case _Notify(:final DioException error):
handler.next(withoutSecrets(error));
case _Quiet(:final DioException error):
handler.reject(withoutSecrets(error));
}
}
/// 실패를 끝까지 다룬다 — 다시 보낸 요청이 실패하면 그 실패도 같은 규칙으로 다시 본다(401 · AUTH_010 마다 한 번씩만 다시 보내 끝난다)
Future<_Outcome> _settle(DioException first) async {
DioException current = first;
while (true) {
final _Step step = await _step(current);
switch (step) {
case _Finish(:final _Outcome outcome):
return outcome;
case _SendAgain(:final RequestOptions options):
try {
return _Resolve(await _retryDio.fetch<Object?>(options));
} on DioException catch (retryError) {
current = retryError;
}
}
}
}
Future<_Step> _step(DioException err) async {
final RequestOptions options = err.requestOptions;
final ApiException apiError = toApiException(err);
final DioException failed = err.error is ApiException
? err
: err.copyWith(error: apiError);
final bool unauthorized = err.response?.statusCode == 401;
final bool restricted = apiError.code == kRestrictedSessionCode;
// 그 요청을 보낸 세션이 끝났다 — 로그아웃 · 세션 끝 뒤에 돌아온 응답은 어떤 것이든 조용히 끝낸다
final Object? sentSession = options.extra[kSessionGenerationExtraKey];
if (sentSession is int && sentSession != _store.sessionGeneration) {
return _Finish(
_Quiet(_ofEndedSession(failed, sentSession, unauthorized)),
);
}
if (options.extra[kAuthHandledExtraKey] == true ||
(!unauthorized && !restricted) ||
_isNoRefreshPath(options)) {
return _Finish(_Notify(failed));
}
if (!_store.hasSession) {
// 토큰 없이 보낸 요청(로그인 전)의 401 은 `AUTH_001` 그대로 알린다
if (!_sentWithToken(options)) return _Finish(_Notify(failed));
// 세션 세대를 싣지 않은 채 세션이 끝났다 — 끝난 세션의 응답처럼 다룬다
return _Finish(
_Quiet(_ofEndedSession(failed, _store.sessionGeneration, unauthorized)),
);
}
return restricted
? _stepRestricted(failed)
: _stepUnauthorized(failed, sentSession);
}
/// 401 — 갱신 한 번 뒤 한 번 다시 보낸다. 다시 보낸 요청도 401 이면 세션이 끊겼다
Future<_Step> _stepUnauthorized(
DioException failed,
Object? sentSession,
) async {
final RequestOptions options = failed.requestOptions;
final ApiException apiError = failed.error! as ApiException;
if (options.extra[kAuthRetriedExtraKey] == true) {
// 새 토큰도 거절됐다 — 세션이 끊겼다(다른 곳에서 로그아웃 · 비밀번호 바꾸기 등)
final ApiException expired = await _refresher.endSession(
traceId: apiError.traceId,
sessionGeneration: sentSession is int ? sentSession : null,
);
return _Finish(_Notify(failed.copyWith(error: expired)));
}
final Object? sentGeneration = options.extra[kAuthGenerationExtraKey];
final RefreshOutcome outcome = await _refresher.refresh(
sentGeneration: sentGeneration is int ? sentGeneration : null,
);
final ApiException? refreshFailure = _failureOf(outcome);
if (refreshFailure == null) {
return _SendAgain(_retryOptions(options, kAuthRetriedExtraKey));
}
final DioException withFailure = failed.copyWith(error: refreshFailure);
return _Finish(
outcome is RefreshDiscarded ? _Quiet(withFailure) : _Notify(withFailure),
);
}
/// 403 `AUTH_010` — 제한을 다시 맞춘다. 옛 토큰이었으면 새 토큰으로 한 번만 다시 보낸다.
Future<_Step> _stepRestricted(DioException failed) async {
final RequestOptions options = failed.requestOptions;
if (options.extra[kRestrictionRetriedExtraKey] == true) {
// 다시 보낸 요청도 막혔다 — 다시 맞추지 않고 알린다
return _Finish(_Notify(failed));
}
final Object? sent = options.extra[kAuthGenerationExtraKey];
final int? sentGeneration = sent is int ? sent : null;
if (sentGeneration != null &&
sentGeneration != _store.generation &&
_store.hasUsableAccessToken) {
// 그사이 다른 요청이 제한을 다시 맞춰 새 토큰을 받았다 — 묻지 않고 다시 보낸다
return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey));
}
final RestrictionCheck check =
_recentCheck(sentGeneration) ?? await _events.requestRecheck();
_lastCheck = (
generation: sentGeneration ?? _store.generation,
check: check,
at: _clock(),
);
return switch (check) {
RestrictionCheck.renewed => _SendAgain(
_retryOptions(options, kRestrictionRetriedExtraKey),
),
// 화면이 그 단계로 간다 · 세션이 바뀌었다 — 알리지 않는다
RestrictionCheck.restricted ||
RestrictionCheck.stale => _Finish(_Quiet(failed)),
RestrictionCheck.unknown => _Finish(_Notify(failed)),
};
}
/// 같은 토큰 세대로 방금 맞춘 결과 — 제한을 확인한 것은 그 세대 동안, 확인하지 못한 것은 [kRestrictionCheckReuse] 동안 다시 쓴다
RestrictionCheck? _recentCheck(int? sentGeneration) {
final ({int generation, RestrictionCheck check, DateTime at})? last =
_lastCheck;
if (last == null || sentGeneration == null) return null;
if (last.generation != sentGeneration) return null;
switch (last.check) {
case RestrictionCheck.restricted:
return last.check;
case RestrictionCheck.unknown:
final Duration since = _clock().difference(last.at);
return !since.isNegative && since < kRestrictionCheckReuse
? last.check
: null;
case RestrictionCheck.renewed:
case RestrictionCheck.stale:
return null;
}
}
/// 끝난 세션의 실패 — 401 은 사용자가 끝냈으면 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝났으면 `AUTH_002`
DioException _ofEndedSession(
DioException failed,
int sessionGeneration,
bool unauthorized,
) {
if (!unauthorized) return failed;
final ApiException apiError = failed.error! as ApiException;
final bool byUser =
_store.endedByUser(sessionGeneration) ?? _store.signedOutByUser;
return failed.copyWith(
error: ApiException.fromCode(
byUser ? 'AUTH_001' : kSessionExpiredCode,
traceId: apiError.traceId,
statusCode: apiError.statusCode,
),
);
}
/// 다시 보낼 요청 — 새 토큰을 붙이고 다시 보냈다는 표시([flag])를 단다
RequestOptions _retryOptions(RequestOptions options, String flag) {
final RequestOptions retry = options.copyWith();
retry.extra[flag] = true;
final Object? data = retry.data;
if (data is FormData) {
// 한 번 보낸 FormData 는 다시 쓸 수 없다
retry.data = data.clone();
}
_attach(retry);
return retry;
}
void _attach(RequestOptions options) {
final String? token = _store.accessToken;
if (token != null) {
options.headers[kAuthorizationHeader] = bearerOf(token);
} else {
options.headers.remove(kAuthorizationHeader);
}
options.extra[kAuthGenerationExtraKey] = _store.generation;
options.extra[kSessionGenerationExtraKey] = _store.sessionGeneration;
}
bool _sentWithToken(RequestOptions options) =>
options.headers.containsKey(kAuthorizationHeader);
bool _isNoRefreshPath(RequestOptions options) =>
ApiPaths.noRefresh.contains(options.path);
DioException _rejectedBeforeSend(RequestOptions options, ApiException error) {
options.extra[kAuthHandledExtraKey] = true;
return DioException(
requestOptions: options,
type: DioExceptionType.unknown,
error: error,
);
}
/// 뜻밖의 예외 — 처리기를 꼭 부르려고 `SYS_001` 실패로 바꾼다
DioException _unexpected(
RequestOptions options,
Object error,
StackTrace stackTrace,
) {
options.extra[kAuthHandledExtraKey] = true;
return DioException(
requestOptions: options,
type: DioExceptionType.unknown,
error: ApiException.fromCode(kFallbackCode),
stackTrace: stackTrace,
);
}
/// 갱신 결과가 실패면 원래 요청에 실을 예외, 성공이면 null
ApiException? _failureOf(RefreshOutcome outcome) {
return switch (outcome) {
RefreshSucceeded() => null,
RefreshRejected(:final ApiException error) => error,
RefreshFailed(:final ApiException error) => error,
// 로그아웃한 뒤라 알리지 않는다 — 부른 쪽은 로그인 전 상태로 본다
RefreshDiscarded() => ApiException.fromCode('AUTH_001'),
};
}
}
/// 실패를 다룬 끝 — 응답으로 바꾸기 · 알리기 · 조용히 끝내기
sealed class _Outcome {
const _Outcome();
}
final class _Resolve extends _Outcome {
const _Resolve(this.response);
final Response<Object?> response;
}
final class _Notify extends _Outcome {
const _Notify(this.error);
final DioException error;
}
final class _Quiet extends _Outcome {
const _Quiet(this.error);
final DioException error;
}
/// 한 차례 판단 — 끝내거나, 다시 보낼 요청
sealed class _Step {
const _Step();
}
final class _Finish extends _Step {
const _Finish(this.outcome);
final _Outcome outcome;
}
final class _SendAgain extends _Step {
const _SendAgain(this.options);
final RequestOptions options;
}
+61 -136
View File
@@ -1,148 +1,73 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/auth_interceptor.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/response_interceptor.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_exception.dart';
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
const String kJwtTokenKey = 'jwt_token';
const Duration _kConnectTimeout = Duration(seconds: 15);
const Duration _kReceiveTimeout = Duration(seconds: 15);
/// 앱의 HTTP 클라이언트 — Dio 둘을 같은 설정으로 만든다.
///
/// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는
/// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`)
/// → [ErrorNotifyInterceptor](알림 — 맨 끝).
/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다.
///
/// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙).
class DioClient {
static final DioClient _instance = DioClient._internal();
late Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
DioClient._(this.dio, this.refresher);
factory DioClient() {
return _instance;
}
factory DioClient.create({
required ApiConfig config,
required TokenStore tokenStore,
required SessionEventBus events,
required ErrorNotifier notifier,
required String userAgent,
Clock clock = systemClock,
HttpClientAdapter? httpClientAdapter,
}) {
final BaseOptions options = BaseOptions(
baseUrl: config.baseUrl,
connectTimeout: config.connectTimeout,
receiveTimeout: config.receiveTimeout,
headers: <String, Object?>{'User-Agent': userAgent},
);
DioClient._internal() {
// 백엔드 글로벌 prefix(/api) 와 일치
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
final Dio bare = Dio(options.copyWith());
final Dio main = Dio(options);
if (httpClientAdapter != null) {
bare.httpClientAdapter = httpClientAdapter;
main.httpClientAdapter = httpClientAdapter;
}
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
final TokenRefresher refresher = TokenRefresher(
bareDio: bare,
store: tokenStore,
events: events,
clock: clock,
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onResponse: (response, handler) {
final payload = response.data;
if (payload is Map<String, dynamic> &&
payload.containsKey('success')) {
if (payload['success'] == true) {
// Global Response Wrapper: 데이터 Unwrap
response.data = payload['data'];
return handler.next(response);
}
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
final error = _readErrorObject(payload);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ApiException.fromCode(
error.code ?? 'BIZ_001',
traceId: error.traceId,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) async {
final apiException = await _toApiException(e);
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
return handler.next(e.copyWith(error: apiException));
},
bare.interceptors.add(ResponseInterceptor());
main.interceptors.addAll(<Interceptor>[
ResponseInterceptor(),
AuthInterceptor(
store: tokenStore,
refresher: refresher,
retryDio: bare,
events: events,
clock: clock,
),
);
ErrorNotifyInterceptor(notifier),
]);
return DioClient._(main, refresher);
}
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
Future<ApiException> _toApiException(DioException e) async {
final response = e.response;
/// 화면 · 저장소가 쓰는 Dio
final Dio dio;
if (response == null) {
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
return ApiException.fromCode('SYS_001');
}
String? traceId;
String? code;
if (response.data is Map<String, dynamic>) {
final error = _readErrorObject(response.data as Map<String, dynamic>);
code = error.code;
traceId = error.traceId;
}
code ??= _resolveCodeByStatus(response.statusCode);
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
await _storage.delete(key: kJwtTokenKey);
code = 'AUTH_002';
}
return ApiException.fromCode(
code,
traceId: traceId,
statusCode: response.statusCode,
);
}
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
String _resolveCodeByStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 429:
return 'REQ_001';
default:
return 'SYS_001';
}
}
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
({String? code, String? traceId}) _readErrorObject(
Map<String, dynamic> payload,
) {
final error = payload['error'];
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
final code = error['code'];
final traceId = error['traceId'];
return (
code: code is String ? code : null,
traceId: traceId is String ? traceId : null,
);
}
Dio get dio => _dio;
/// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나)
final TokenRefresher refresher;
}
+72 -6
View File
@@ -1,9 +1,75 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/dio_client.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dio_client.dart';
// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다.
// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다.
final dioProvider = Provider<Dio>((ref) {
return DioClient().dio;
});
/// 지금 시각 — 시험은 손으로 돌리는 시계로 덮어쓴다
final Provider<Clock> clockProvider = Provider<Clock>((Ref ref) => systemClock);
/// API 연결 설정 (`.env` 의 `API_URL`)
final Provider<ApiConfig> apiConfigProvider = Provider<ApiConfig>(
(Ref ref) => ApiConfig.fromEnv(),
);
/// User-Agent — main 이 기기 정보로 만든 값으로 덮어쓴다(`loadAppDeviceInfo`). 덮지 않으면 제품 이름만.
final Provider<String> userAgentProvider = Provider<String>(
(Ref ref) => kUserAgentProduct,
);
/// 세션 알림 통로 — 인터셉터가 알리고 세션 노티파이어가 듣는다
final Provider<SessionEventBus> sessionEventsProvider =
Provider<SessionEventBus>((Ref ref) {
final SessionEventBus bus = SessionEventBus();
ref.onDispose(bus.close);
return bus;
});
/// 전역 ScaffoldMessenger 키 — `MaterialApp.router(scaffoldMessengerKey:)` 에 건다
final Provider<GlobalKey<ScaffoldMessengerState>> scaffoldMessengerKeyProvider =
Provider<GlobalKey<ScaffoldMessengerState>>(
(Ref ref) => GlobalKey<ScaffoldMessengerState>(),
);
/// API 실패 알림 (같은 코드는 3초 안에 다시 띄우지 않는다)
final Provider<ErrorNotifier> errorNotifierProvider = Provider<ErrorNotifier>(
(Ref ref) => ErrorNotifier(
messengerKey: ref.watch(scaffoldMessengerKeyProvider),
clock: ref.watch(clockProvider),
),
);
/// 시험이 가짜 서버(HttpClientAdapter)를 끼우는 자리 — 앱에서는 null(실제 네트워크)
final Provider<HttpClientAdapter?> httpClientAdapterProvider =
Provider<HttpClientAdapter?>((Ref ref) => null);
/// HTTP 클라이언트 — Dio 와 토큰 갱신을 함께 만든다
final Provider<DioClient> dioClientProvider = Provider<DioClient>(
(Ref ref) => DioClient.create(
config: ref.watch(apiConfigProvider),
tokenStore: ref.watch(tokenStoreProvider),
events: ref.watch(sessionEventsProvider),
notifier: ref.watch(errorNotifierProvider),
userAgent: ref.watch(userAgentProvider),
clock: ref.watch(clockProvider),
httpClientAdapter: ref.watch(httpClientAdapterProvider),
),
);
/// 전역 Dio — 저장소는 `ref.watch(dioProvider)` 로 받는다.
/// 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸려 있다.
final Provider<Dio> dioProvider = Provider<Dio>(
(Ref ref) => ref.watch(dioClientProvider).dio,
);
/// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다
final Provider<TokenRefresher> tokenRefresherProvider =
Provider<TokenRefresher>(
(Ref ref) => ref.watch(dioClientProvider).refresher,
);
+73
View File
@@ -0,0 +1,73 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
/// 같은 코드의 알림을 다시 띄우지 않는 시간 — 웹 `useApi.ts` 의 `DUPLICATE_ALERT_WINDOW_MS` 와 같다
const Duration kDuplicateAlertWindow = Duration(seconds: 3);
/// API 실패 알림 — 앱에 하나, 전역 `ScaffoldMessenger` 키 하나로 띄운다(웹 `showErrorUI` 자리).
///
/// 문구는 에러 코드 사전에서만 온다([ApiException.message]). 다른 알림 모양으로 바꿀 때도 [show] 하나만 바꾼다.
class ErrorNotifier {
ErrorNotifier({
required GlobalKey<ScaffoldMessengerState> messengerKey,
Clock clock = systemClock,
Duration duplicateWindow = kDuplicateAlertWindow,
}) : _messengerKey = messengerKey,
_clock = clock,
_duplicateWindow = duplicateWindow;
final GlobalKey<ScaffoldMessengerState> _messengerKey;
final Clock _clock;
final Duration _duplicateWindow;
String? _lastCode;
DateTime? _lastShownAt;
/// 알린다 — 같은 코드를 [kDuplicateAlertWindow] 안에 다시 받으면 띄우지 않는다
void show(ApiException error) {
final DateTime now = _clock();
final DateTime? lastShownAt = _lastShownAt;
if (_lastCode == error.code &&
lastShownAt != null &&
now.difference(lastShownAt) < _duplicateWindow) {
return;
}
_lastCode = error.code;
_lastShownAt = now;
display(error);
}
/// 실제로 띄운다 — 스낵바 한 줄(앞의 것은 닫는다). 모양을 바꿀 때는 이것만 바꾼다
@protected
void display(ApiException error) {
final ScaffoldMessengerState? messenger = _messengerKey.currentState;
if (messenger == null) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(error.message)));
}
}
/// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다).
///
/// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패
/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다).
class ErrorNotifyInterceptor extends Interceptor {
ErrorNotifyInterceptor(this._notifier);
final ErrorNotifier _notifier;
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final Object? error = err.error;
if (err.type != DioExceptionType.cancel &&
error is ApiException &&
!isSilentFor(err.requestOptions, error.code)) {
_notifier.show(error);
}
handler.next(err);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:dio/dio.dart';
/// 요청마다 알림 끄기 — `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록
/// (`{kSilentExtraKey: {'AUTH_004', 'AUTH_006'}}`). 웹 `useApi` 의 `silent` 와 같다.
///
/// 끈 실패도 예외(`DioException.error` 의 `ApiException`)로는 그대로 온다 — 화면이 `code` 로 가른다.
const String kSilentExtraKey = 'silent';
/// 비밀(비밀번호 · 코드 · 리프레시 토큰)이 본문에 든 요청 — 실패 예외에서 본문을 비운다([withoutSecrets]).
/// 화면이 그 예외를 `AsyncValue` 로 상태에 두어도 비밀이 남지 않게. 로그인 · 코드 · 비밀번호 단계 · logout 이 단다.
const String kSecretBodyExtraKey = 'secret.body';
/// `Authorization` 머리 이름
const String kAuthorizationHeader = 'Authorization';
/// Bearer 머리 값
String bearerOf(String accessToken) => 'Bearer $accessToken';
// ── 인증 인터셉터가 쓰는 내부 표시 (화면 · 저장소는 쓰지 않는다) ──
/// 401 → 갱신 뒤 다시 보낸 요청 — 401 으로는 한 번만 다시 보낸다
const String kAuthRetriedExtraKey = 'auth.retried';
/// `AUTH_010` → 제한 다시 맞추기 뒤 다시 보낸 요청 — `AUTH_010` 으로는 한 번만 다시 보낸다
const String kRestrictionRetriedExtraKey = 'auth.restrictionRetried';
/// 보낼 때의 토큰 세대(TokenStore.generation) — 그사이 다른 요청이 갱신했는지 견준다
const String kAuthGenerationExtraKey = 'auth.generation';
/// 보낼 때의 세션 세대(TokenStore.sessionGeneration) — 그 요청을 보낸 세션이 끝났는지 견준다
const String kSessionGenerationExtraKey = 'auth.session';
/// 인증 인터셉터가 이미 결론 낸 실패 — 다시 갱신하지 않는다
const String kAuthHandledExtraKey = 'auth.handled';
/// 알림 끄기 · 비밀 본문 Options 를 만든다
abstract final class ApiOptions {
/// 모든 실패의 알림을 끈다
static Options silent() =>
Options(extra: <String, Object?>{kSilentExtraKey: true});
/// [codes] 의 알림만 끈다 — 화면이 칸 아래에 보이는 실패(예: 로그인의 `AUTH_004`)
static Options silentFor(Set<String> codes) =>
Options(extra: <String, Object?>{kSilentExtraKey: codes});
/// 비밀이 본문에 든 요청 — [silent] 는 `true` 또는 코드 목록(`Set<String>`)
static Options secret({Object? silent, Map<String, Object?>? headers}) =>
Options(
extra: <String, Object?>{
kSecretBodyExtraKey: true,
kSilentExtraKey: ?silent,
},
headers: headers,
);
}
/// 이 요청의 [code] 실패를 알리지 않는가
bool isSilentFor(RequestOptions options, String code) {
final Object? flag = options.extra[kSilentExtraKey];
if (flag == true) return true;
if (flag is Iterable<Object?>) return flag.contains(code);
return false;
}
/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 본문([kSecretBodyExtraKey]).
///
/// 인증 인터셉터가 실패를 내보내기 직전에 부른다(응답의 RequestOptions 도). 같은 예외를 돌려준다.
DioException withoutSecrets(DioException error) {
_scrub(error.requestOptions);
final RequestOptions? responseOptions = error.response?.requestOptions;
if (responseOptions != null &&
!identical(responseOptions, error.requestOptions)) {
_scrub(responseOptions);
}
return error;
}
void _scrub(RequestOptions options) {
options.headers.remove(kAuthorizationHeader);
if (options.extra[kSecretBodyExtraKey] == true) options.data = null;
}
@@ -0,0 +1,52 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:dio/dio.dart';
/// 표준 응답을 푼다 (응답 포맷 규칙) — 인터셉터 줄의 맨 앞에 둔다.
///
/// - `{ success: true, data }` → `data` 만 남긴다. 스트림 · 바이트(첨부 받기 · 실시간 알림)는 Map 이 아니라 그대로 둔다.
/// - `{ success: false }` 가 200 으로 오면(외부 게이트웨이 등 — 서버는 4xx 로 내린다) 실패로 바꾼다.
/// - 실패는 모두 `DioException.error` 에 [ApiException] 을 싣는다 — 뒤의 인증 · 알림 인터셉터가 코드로 가른다.
class ResponseInterceptor extends Interceptor {
@override
void onResponse(
Response<Object?> response,
ResponseInterceptorHandler handler,
) {
final Object? payload = response.data;
if (payload is! Map<String, Object?> || !payload.containsKey('success')) {
handler.next(response);
return;
}
if (payload['success'] == true) {
response.data = payload['data'];
handler.next(response);
return;
}
final ({String? code, String? traceId}) body = readErrorBody(payload);
handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: ApiException.fromCode(
body.code ?? 'BIZ_001',
traceId: body.traceId,
statusCode: response.statusCode,
),
),
// 뒤의 오류 인터셉터(인증 · 알림)도 지나게 한다
true,
);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err.copyWith(error: toApiException(err)));
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:io';
import 'package:dio/dio.dart';
/// 응답의 `Date` 머리(서버 시각) — 없거나 읽지 못하면 null.
///
/// 토큰의 남은 시간을 서버 시계로 재는 데 쓴다(TokenStore — 기기 시계가 틀려도 만료를 맞게 본다).
DateTime? serverTimeOf(Response<Object?> response) {
final String? value = response.headers.value(HttpHeaders.dateHeader);
if (value == null || value.isEmpty) return null;
try {
return HttpDate.parse(value).toUtc();
} on HttpException {
return null;
} on FormatException {
return null;
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:async';
/// 제한(`AUTH_010`)을 다시 맞춘 결과 — 웹 `frontend/src/composables/useApi.ts` 의 `RestrictionCheck` 와 같다.
enum RestrictionCheck {
/// 옛 토큰이었다 — 제한 없이 새 토큰을 받았다. 원래 요청을 한 번 다시 보내도 된다
renewed,
/// 제한을 확인했다 — 세션이 그 단계 화면으로 옮긴다. 알리지 않는다
restricted,
/// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않는다. 알리지 않는다
stale,
/// 확인하지 못했다(장애 · 듣는 쪽 없음) — 원래 실패를 알린다
unknown,
}
/// 제한을 다시 맞추는 데 기다리는 최대 시간 — 세션 확인(me)과 갱신이 안쪽 Dio 의 연결 · 받기 한도
/// (`api_config.dart` — 15초 + 15초)를 다 써도 그 결과가 먼저 오게, 두 요청의 한도 합(60초)에 5초를 더했다.
/// 듣는 쪽이 답하지 않아도 요청이 끝없이 서 있지 않게 하는 안전 한도다. API 한도를 바꾸면 함께 바꾼다(시험이 견준다).
const Duration kRestrictionRecheckTimeout = Duration(seconds: 65);
/// 네트워크 층이 세션 노티파이어에 알리는 일.
///
/// 인터셉터는 세션 노티파이어를 모른다(노티파이어가 Dio 를 쓰므로 서로 물리지 않게) —
/// 여기에 알리기만 하고, 세션 노티파이어가 듣고 상태를 바꾼다.
sealed class SessionEvent {
const SessionEvent();
}
/// 갱신이 거절됐다(401 · 유예 응답) — 토큰은 이미 지웠다. 로그인 화면으로 간다.
final class SessionEnded extends SessionEvent {
const SessionEnded();
}
/// 403 `AUTH_010` — 제한된 세션이다. 알림 없이 세션을 다시 확인해 그 단계로 옮기고 결과를 [reply] 로 돌려준다(계약 6절).
final class SessionRecheckNeeded extends SessionEvent {
SessionRecheckNeeded();
final Completer<RestrictionCheck> _answer = Completer<RestrictionCheck>();
/// 다시 맞춘 결과 — 듣는 쪽이 [reply] 로 채운다
Future<RestrictionCheck> get answer => _answer.future;
/// 결과를 돌려준다 — 처음 한 번만 쓴다
void reply(RestrictionCheck check) {
if (!_answer.isCompleted) _answer.complete(check);
}
}
/// 토큰을 갱신했다 — 응답에 실린 세션 칸(제한 · 유예)으로 화면을 맞춘다(계약 6절 — 갱신 응답에도 늘 싣는다).
final class SessionRefreshed extends SessionEvent {
const SessionRefreshed(this.body);
/// 갱신 응답의 세션 칸만(`email` · `name` · `restriction` · `twoFactorDeadline`) — 토큰은 싣지 않는다
final Map<String, Object?> body;
}
/// 갱신 응답에서 세션 칸만 남긴다 — 토큰(`tokens`)이 알림 · 결과 객체에 실려 다니지 않게
Map<String, Object?> sessionFieldsOf(Map<String, Object?> body) =>
<String, Object?>{
for (final MapEntry<String, Object?> entry in body.entries)
if (kSessionFieldNames.contains(entry.key)) entry.key: entry.value,
};
/// 세션 칸 이름 (계약 1 · 6절)
const Set<String> kSessionFieldNames = <String>{
'email',
'name',
'restriction',
'twoFactorDeadline',
};
/// 세션 알림 통로 — 앱에 하나 (dio_provider 의 sessionEventsProvider)
class SessionEventBus {
SessionEventBus({Duration recheckTimeout = kRestrictionRecheckTimeout})
: _recheckTimeout = recheckTimeout;
final Duration _recheckTimeout;
final StreamController<SessionEvent> _controller =
StreamController<SessionEvent>.broadcast();
Stream<SessionEvent> get stream => _controller.stream;
void emit(SessionEvent event) {
if (!_controller.isClosed) _controller.add(event);
}
/// `AUTH_010` 을 받았다 — 세션 노티파이어에 제한을 다시 맞춰 달라고 하고 그 결과를 기다린다.
/// 듣는 쪽이 없거나 [kRestrictionRecheckTimeout] 안에 답이 없으면 [RestrictionCheck.unknown].
Future<RestrictionCheck> requestRecheck() {
if (_controller.isClosed || !_controller.hasListener) {
return Future<RestrictionCheck>.value(RestrictionCheck.unknown);
}
final SessionRecheckNeeded event = SessionRecheckNeeded();
_controller.add(event);
return event.answer.timeout(
_recheckTimeout,
onTimeout: () => RestrictionCheck.unknown,
);
}
Future<void> close() => _controller.close();
}
+304
View File
@@ -0,0 +1,304 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
/// 클라이언트 전용 세션 만료 코드 (에러 코드 규칙 "AUTH_002")
const String kSessionExpiredCode = 'AUTH_002';
/// 갱신이 일시 장애로 실패하면 같은 토큰 세대 동안 이만큼은 그 결과를 다시 쓴다 —
/// 연결이 끊긴 동안 줄 선 요청마다 갱신을 보내 그때마다 연결 한도를 기다리지 않게
const Duration kRefreshFailureReuse = Duration(seconds: 5);
/// 갱신 결과
sealed class RefreshOutcome {
const RefreshOutcome();
}
/// 새 토큰을 받아 두었다 — [body] 는 갱신 응답의 세션 칸만(토큰 없음). 그사이 다른 요청이 이미 받아 두었으면 null.
final class RefreshSucceeded extends RefreshOutcome {
const RefreshSucceeded([this.body]);
final Map<String, Object?>? body;
}
/// 세션이 끝났다 — 서버가 401 로 거절했거나 유예 응답이었다. 토큰은 지웠고 [error] 는 `AUTH_002` 다.
final class RefreshRejected extends RefreshOutcome {
const RefreshRejected(this.error);
final ApiException error;
}
/// 일시 장애(연결 끊김 · 5xx) — 세션은 그대로 두고 [error] 를 알린다 (계약 1절: 로그아웃으로 다루지 않는다)
final class RefreshFailed extends RefreshOutcome {
const RefreshFailed(this.error);
final ApiException error;
}
/// 갱신하는 사이 로그아웃했다 — 받은 토큰은 쓰지 않고 서버 세션도 끝냈다(계약 1절)
final class RefreshDiscarded extends RefreshOutcome {
const RefreshDiscarded();
}
/// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다
/// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`).
/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다.
Future<void> postAppLogout(
Dio dio, {
required String? refreshToken,
required String? accessToken,
}) async {
await dio.post<Object?>(
ApiPaths.appLogout,
data: <String, Object?>{'refreshToken': ?refreshToken},
options: ApiOptions.secret(
silent: true,
headers: <String, Object?>{
if (accessToken != null) kAuthorizationHeader: bearerOf(accessToken),
},
),
);
}
/// 인증 인터셉터 줄 **밖에서** 보내는 인증 요청 — 갱신 · 세션 확인 · 조용한 로그아웃.
///
/// 갱신은 **한 번에 하나만** 한다. 리프레시 토큰은 1회용이라 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이 된다.
/// 인증 인터셉터(요청 전 미리 갱신 · 401 뒤 갱신)와 세션 노티파이어가 모두 이것을 지나
/// 진행 중인 갱신이 있으면 그 결과를 함께 기다린다.
///
/// 요청은 인증 인터셉터가 없는 Dio(`bareDio`)로 보낸다 — 인증 인터셉터는 `QueuedInterceptor` 라
/// 그 오류 처리 안에서 같은 줄로 요청을 보내면 그 요청의 오류가 지금 처리 뒤에 줄을 서서 서로 기다린다.
class TokenRefresher {
TokenRefresher({
required Dio bareDio,
required TokenStore store,
required SessionEventBus events,
Clock clock = systemClock,
}) : _dio = bareDio,
_store = store,
_events = events,
_clock = clock;
final Dio _dio;
final TokenStore _store;
final SessionEventBus _events;
final Clock _clock;
Future<RefreshOutcome>? _inFlight;
({int generation, DateTime at, RefreshFailed outcome})? _lastFailure;
/// 갱신한다.
///
/// [sentGeneration] 은 실패한 요청을 보낼 때의 토큰 세대다. 그사이 다른 요청이 이미 새 토큰을
/// 받아 두었으면 다시 갱신하지 않고 [RefreshSucceeded] 로 답한다.
/// 같은 세대에서 [kRefreshFailureReuse] 안에 일시 장애로 실패했으면 다시 보내지 않고 그 결과를 돌려준다.
Future<RefreshOutcome> refresh({int? sentGeneration}) {
final Future<RefreshOutcome>? inFlight = _inFlight;
if (inFlight != null) return inFlight;
if (sentGeneration != null &&
sentGeneration != _store.generation &&
_store.hasUsableAccessToken) {
return Future<RefreshOutcome>.value(const RefreshSucceeded());
}
final RefreshFailed? recent = _recentFailure();
if (recent != null) return Future<RefreshOutcome>.value(recent);
final Future<RefreshOutcome> run = _runGuarded();
_inFlight = run;
run.whenComplete(() {
if (identical(_inFlight, run)) _inFlight = null;
}).ignore();
return run;
}
/// 일시 장애 기억을 지운다 — 사용자가 [다시 시도]를 눌렀을 때
void forgetFailure() => _lastFailure = null;
/// 세션을 끝낸다 — 토큰을 지우고 세션 노티파이어에 알린다. 돌려주는 예외는 `AUTH_002` 다.
///
/// [sessionGeneration] 을 주면 그 세션이 아직일 때만 지운다 — 그사이 로그아웃했으면(다른 직원이 로그인했어도)
/// 그 세션을 건드리지 않는다.
Future<ApiException> endSession({
String? traceId,
int? sessionGeneration,
}) async {
final ApiException expired = ApiException.fromCode(
kSessionExpiredCode,
traceId: traceId,
statusCode: 401,
);
if (sessionGeneration != null &&
sessionGeneration != _store.sessionGeneration) {
return expired;
}
try {
await _store.clear();
} on PlatformException {
// 메모리의 토큰은 이미 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 다시 지운다
}
_events.emit(const SessionEnded());
return expired;
}
/// 지금 세션(`GET /auth/me`)을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때(인터셉터가 기다리는 중이라).
/// 응답의 `data` 를 돌려주고, 실패는 `DioException`(`error` 에 `ApiException` · 토큰을 지운 것)으로 던진다. 알림은 띄우지 않는다.
Future<Map<String, Object?>> readSession() async {
final String? accessToken = _store.accessToken;
final Response<Object?> response;
try {
response = await _dio.get<Object?>(
ApiPaths.me,
options: Options(
extra: <String, Object?>{kSilentExtraKey: true},
headers: <String, Object?>{
if (accessToken != null)
kAuthorizationHeader: bearerOf(accessToken),
},
),
);
} on DioException catch (error) {
throw withoutSecrets(error);
}
final Object? data = response.data;
if (data is! Map<String, Object?>) {
throw withoutSecrets(
apiFailure(response.requestOptions, kFallbackCode, response: response),
);
}
return data;
}
/// 조용한 앱 logout — 버린 토큰의 세션을 서버에서 끝낸다(닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다)
Future<void> logoutQuietly({
required String? refreshToken,
required String? accessToken,
}) async {
if (refreshToken == null && accessToken == null) return;
try {
await postAppLogout(
_dio,
refreshToken: refreshToken,
accessToken: accessToken,
);
} on DioException {
// 위 설명대로 둔다
}
}
RefreshFailed? _recentFailure() {
final ({int generation, DateTime at, RefreshFailed outcome})? last =
_lastFailure;
if (last == null || last.generation != _store.generation) return null;
final Duration since = _clock().difference(last.at);
if (since.isNegative || since >= kRefreshFailureReuse) return null;
return last.outcome;
}
Future<RefreshOutcome> _runGuarded() async {
RefreshOutcome outcome;
try {
outcome = await _run();
} on PlatformException {
// 보안 저장소를 읽지 못했다 — 세션은 그대로 두고 일시 장애로 알린다
outcome = RefreshFailed(ApiException.fromCode(kFallbackCode));
}
_lastFailure = outcome is RefreshFailed
? (generation: _store.generation, at: _clock(), outcome: outcome)
: null;
return outcome;
}
Future<RefreshOutcome> _run() async {
final int startGeneration = _store.generation;
final int startSession = _store.sessionGeneration;
final String? refreshToken = await _store.readRefreshToken();
// 읽는 사이 로그아웃했다 — 그 세션을 끝내지 않는다(로그아웃이 이미 끝냈다)
if (_store.generation != startGeneration) return const RefreshDiscarded();
if (refreshToken == null) {
return RefreshRejected(await endSession(sessionGeneration: startSession));
}
final Response<Object?> response;
try {
response = await _dio.post<Object?>(
ApiPaths.appRefresh,
data: <String, Object?>{'refreshToken': refreshToken},
options: ApiOptions.secret(silent: true),
);
} on DioException catch (error) {
if (_store.generation != startGeneration) return const RefreshDiscarded();
final ApiException apiError = toApiException(error);
if (error.response?.statusCode == 401) {
return RefreshRejected(
await endSession(
traceId: apiError.traceId,
sessionGeneration: startSession,
),
);
}
return RefreshFailed(apiError);
}
final Object? data = response.data;
final Map<String, Object?> body = data is Map<String, Object?>
? data
: const <String, Object?>{};
final AuthTokens? tokens = AuthTokens.tryParse(
body['tokens'],
serverTime: serverTimeOf(response),
);
if (_store.generation != startGeneration) {
// 갱신하는 사이 로그아웃했다 — 서버가 로그아웃 뒤에 새 토큰을 줄 수 있어 그 세션도 끝낸다
if (tokens != null) {
unawaited(
logoutQuietly(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
),
);
}
return const RefreshDiscarded();
}
if (tokens != null) {
try {
await _store.save(tokens);
} on PlatformException {
// 새 토큰을 보안 저장소에 두지 못했다 — 이번 실행 동안 메모리의 새 토큰으로 이어 가고,
// 다음 저장 · 읽기 때 다시 쓴다(TokenStore). 그 전에 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤
// 재사용으로 세션을 끊는다 — 계약 1절 "앱 로그인"의 알려진 한계와 같은 결이다.
}
final Map<String, Object?> session = sessionFieldsOf(body);
_events.emit(SessionRefreshed(session));
return RefreshSucceeded(session);
}
if (body.containsKey('tokens') && body['tokens'] == null) {
// 30초 유예 응답(새 토큰 없이 200) — 계약 1절 "앱 로그인": 세션 끝으로 다룬다.
// 앱은 갱신을 한 번에 하나만 하므로 앞 갱신의 응답을 잃은 경우이고 이어 쓸 토큰이 없다.
// 메모리의 액세스 토큰이 아직 있으면 그것으로 서버 세션도 끝낸다("로그인된 기기"에 14일 남지 않게 — sid 로).
final String? accessToken = _store.accessToken;
final ApiException expired = await endSession(
sessionGeneration: startSession,
);
if (accessToken != null) {
unawaited(logoutQuietly(refreshToken: null, accessToken: accessToken));
}
return RefreshRejected(expired);
}
// 모양이 틀린 응답 — 서버 쪽 문제로 보고 세션은 그대로 둔다
return RefreshFailed(ApiException.fromCode(kFallbackCode));
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// 기종 · 판 글자의 최대 길이 — 머리글이 지나치게 길어지지 않게
const int _kUserAgentPartMaxLength = 64;
/// 서버가 앱 세션을 알아보는 User-Agent — `ARASYSTEM-App/<판> (Android <판>; <기종>)` (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션").
///
/// 머리글 값은 ASCII 만 보낼 수 있고 `;` `(` `)` 는 꼴을 깨므로, 기기가 준 글자에서 영문 · 숫자 · 공백 · `. _ -` 만 남긴다.
/// 모르는 칸은 뺀다(예: `ARASYSTEM-App/1.0.0 (Android 15)`).
String buildUserAgent({
required String? appVersion,
required String osName,
String? osVersion,
String? model,
}) {
final String version = _clean(appVersion);
final String product = version.isEmpty
? kUserAgentProduct
: '$kUserAgentProduct/$version';
final String os = <String>[
_clean(osName),
_clean(osVersion),
].where((String part) => part.isNotEmpty).join(' ');
final List<String> details = <String>[
os,
_clean(model),
].where((String part) => part.isNotEmpty).toList();
return details.isEmpty ? product : '$product (${details.join('; ')})';
}
String _clean(String? value) {
if (value == null) return '';
final String kept = value
.replaceAll(RegExp(r'[^A-Za-z0-9 ._\-]'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return kept.length > _kUserAgentPartMaxLength
? kept.substring(0, _kUserAgentPartMaxLength).trim()
: kept;
}
/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다.
Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async {
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on PlatformException {
appVersion = null;
}
return (
appVersion: appVersion,
userAgent: await _deviceUserAgent(appVersion),
);
}
Future<String> _deviceUserAgent(String? appVersion) async {
try {
final DeviceInfoPlugin device = DeviceInfoPlugin();
if (Platform.isAndroid) {
final AndroidDeviceInfo android = await device.androidInfo;
return buildUserAgent(
appVersion: appVersion,
osName: 'Android',
osVersion: android.version.release,
model: android.model,
);
}
if (Platform.isIOS) {
final IosDeviceInfo ios = await device.iosInfo;
return buildUserAgent(
appVersion: appVersion,
osName: 'iOS',
osVersion: ios.systemVersion,
model: ios.utsname.machine,
);
}
} on PlatformException {
// 아래의 아는 것만 싣는다
}
return buildUserAgent(
appVersion: appVersion,
osName: Platform.operatingSystem,
);
}
@@ -0,0 +1,12 @@
/// 앱 전체에서 쓰는 이름 · 식별 값.
///
/// 식별자(Android applicationId · iOS bundle id `kr.it.arasystem.app`)는 플랫폼 설정 파일에 있다 —
/// 스토어에 올린 뒤에는 바꿀 수 없다.
library;
/// 홈 화면 · 작업 전환 화면에 보이는 앱 이름
const String kAppName = 'ARA SYSTEM';
/// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)`
/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다)
const String kUserAgentProduct = 'ARASYSTEM-App';
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱 판(pubspec `version` 의 build-name — 예 `1.0.0`). main 이 기기 정보를 읽어 덮어쓴다.
/// 읽지 못했으면 null — 설정 › 앱 정보 · 라이선스 화면은 판 없이 보인다.
final Provider<String?> appVersionProvider = Provider<String?>(
(Ref ref) => null,
);
@@ -0,0 +1,24 @@
/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다.
/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`).
///
/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고
/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class ScreenTitles {
/// 세션을 되살리는 동안
static const String start = '시작하는 중';
/// 로그인 — 비밀번호 단계
static const String login = '로그인';
/// 로그인 — 2단계 인증 코드 단계
static const String loginCode = '인증 코드 입력';
/// 제한된 세션 — 새 비밀번호 정하기 (passwordChange)
static const String passwordChange = '새 비밀번호 정하기';
/// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup)
static const String twoFactorSetup = '2단계 인증 켜기';
/// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만)
static const String restricted = '로그인 제한';
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
/// 사내 시스템 모듈 목록 — 아래 탭 막대(웹의 전역 앱 레일)는 이 목록 하나만 보고 그린다.
///
/// **짝: 웹 `frontend/src/shared/constants/app-modules.ts` 의 `APP_MODULES`** — 같은 id · 이름 · 차례다.
/// 한쪽을 바꾸면 둘 다 바꾼다(아이콘은 플랫폼마다 따로 — 웹은 lucide, 앱은 Material).
///
/// 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭에 보인다([enabledAppModules]).
/// 새 모듈을 붙이는 법: 여기 항목을 켜고 → `features/<모듈>/<모듈>_routes.dart` 에 라우트 묶음을 만들고 →
/// `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다(빠뜨리면 컴파일 · 시험이 막는다).
enum AppModuleId {
mail,
calendar,
files,
contacts,
settings;
/// 주소 · 알림의 모듈 이름 — 없는 이름이면 null
static AppModuleId? fromName(String? name) {
for (final AppModuleId id in AppModuleId.values) {
if (id.name == name) return id;
}
return null;
}
}
/// 모듈 하나
@immutable
class AppModule {
const AppModule({
required this.id,
required this.label,
required this.icon,
required this.selectedIcon,
required this.isEnabled,
});
final AppModuleId id;
/// 탭 막대 · 화면 머리에 보이는 이름 (웹 `label` 과 같다)
final String label;
final IconData icon;
/// 고른 탭의 아이콘
final IconData selectedIcon;
/// 첫 판에 켜는가 — 꺼진 모듈은 탭 · 주소 · 알림 어디에도 열리지 않는다
final bool isEnabled;
/// 모듈 첫 화면 경로 — 주소는 모듈 이름부터 시작한다(`/mail/...` · `/settings/...`)
String get rootPath => '/${id.name}';
}
/// 모듈 목록 — 웹과 같은 차례(설정은 맨 끝)
const List<AppModule> kAppModules = <AppModule>[
AppModule(
id: AppModuleId.mail,
label: '메일',
icon: Icons.mail_outline,
selectedIcon: Icons.mail,
isEnabled: true,
),
AppModule(
id: AppModuleId.calendar,
label: '캘린더',
icon: Icons.calendar_today_outlined,
selectedIcon: Icons.calendar_today,
isEnabled: false,
),
AppModule(
id: AppModuleId.files,
label: '파일',
icon: Icons.folder_outlined,
selectedIcon: Icons.folder,
isEnabled: false,
),
// 웹은 8단계부터 켜져 있다 — 앱은 첫 판 밖
AppModule(
id: AppModuleId.contacts,
label: '주소록',
icon: Icons.people_outline,
selectedIcon: Icons.people,
isEnabled: false,
),
AppModule(
id: AppModuleId.settings,
label: '설정',
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
isEnabled: true,
),
];
/// 로그인 뒤 첫 모듈 · 모르는 주소가 돌아가는 곳
const AppModuleId kHomeModuleId = AppModuleId.mail;
/// 켜진 모듈만 — 목록 차례 그대로 (탭 막대의 차례)
List<AppModule> enabledAppModules([List<AppModule> modules = kAppModules]) =>
modules.where((AppModule module) => module.isEnabled).toList();
/// id 로 모듈을 찾는다
AppModule findAppModule(
AppModuleId id, [
List<AppModule> modules = kAppModules,
]) => modules.firstWhere((AppModule module) => module.id == id);
/// 경로의 첫 칸으로 켜진 모듈을 찾는다(`/mail/messages/1` → 메일) — 없거나 꺼졌으면 null
AppModule? enabledModuleOfPath(
String path, [
List<AppModule> modules = kAppModules,
]) {
final List<String> segments = Uri.parse(path).pathSegments;
if (segments.isEmpty) return null;
final AppModuleId? id = AppModuleId.fromName(segments.first);
if (id == null) return null;
final AppModule module = findAppModule(id, modules);
return module.isEnabled ? module : null;
}
/// 홈(로그인 뒤 첫 화면 · 모르는 주소가 돌아가는 곳)의 경로 — [modules] 에서 홈 모듈이 켜져 있으면 그 첫 화면,
/// 꺼져 있으면 켜진 첫 모듈의 첫 화면
String homeLocationOf([List<AppModule> modules = kAppModules]) {
final AppModule home = findAppModule(kHomeModuleId, modules);
if (home.isEnabled) return home.rootPath;
final List<AppModule> enabled = enabledAppModules(modules);
return enabled.isEmpty ? home.rootPath : enabled.first.rootPath;
}
/// 기본 목록의 홈 경로 (`/mail`)
String get homeLocation => homeLocationOf();
@@ -0,0 +1,62 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
/// 모듈이 라우터에 내놓는 것 — 모듈마다 `features/<모듈>/<모듈>_routes.dart` 에 하나.
///
/// `core/router` 가 켜진 모듈 목록을 보고 이것들을 모아 탭(StatefulShellBranch)마다 하나씩 붙인다.
/// 모듈끼리는 서로 import 하지 않는다 — 설정 화면에 절을 더할 때도 [settingsSections] 로 내놓기만 한다.
@immutable
class ModuleRoutes {
const ModuleRoutes({
required this.id,
required this.buildRoutes,
this.locationOfItem,
this.settingsSections = const <ModuleSettingsSection>[],
});
final AppModuleId id;
/// 탭 안의 라우트 — 첫 화면은 모듈 경로(`/<모듈>`)여야 한다.
/// 탭 막대 없이 여는 전체 화면(읽기 · 쓰기)은 `parentNavigatorKey: context.rootNavigatorKey` 로 연다.
final List<RouteBase> Function(ModuleRouteContext context) buildRoutes;
/// 알림 · 깊은 링크의 항목 id → 그 화면 경로. 없으면 모듈 첫 화면으로 간다.
final String Function(String itemId)? locationOfItem;
/// 설정 탭에 더할 이 모듈의 절 (예: 나중에 캘린더 알림)
final List<ModuleSettingsSection> settingsSections;
}
/// 라우트를 만들 때 모듈이 받는 것
@immutable
class ModuleRouteContext {
const ModuleRouteContext({
required this.rootNavigatorKey,
this.settingsSections = const <ModuleSettingsSection>[],
});
/// 탭 막대 위에 덮어 여는 전체 화면의 navigator
final GlobalKey<NavigatorState> rootNavigatorKey;
/// 다른 모듈이 내놓은 설정 절 — 설정 모듈만 쓴다
final List<ModuleSettingsSection> settingsSections;
}
/// 설정 탭의 절 하나 — 설정 모듈이 자기 라우트 아래(`/settings/<path>`)에 붙인다
@immutable
class ModuleSettingsSection {
const ModuleSettingsSection({
required this.title,
required this.path,
required this.builder,
});
/// 설정 목록에 보이는 이름
final String title;
/// `/settings/` 아래의 칸 — 예: `calendar`
final String path;
final GoRouterWidgetBuilder builder;
}
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다).
///
/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와
/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다:
/// - 로그인을 마치면(제한 없음) [register]
/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다.
/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다
/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다).
abstract interface class PushRegistrar {
Future<void> register();
/// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다)
Future<void> unregister();
}
/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다
const Duration kPushUnregisterTimeout = Duration(seconds: 5);
/// 지금의 구현 — 아무것도 하지 않는다
class NoopPushRegistrar implements PushRegistrar {
const NoopPushRegistrar();
@override
Future<void> register() async {}
@override
Future<void> unregister() async {}
}
/// 푸시 기기 등록
final Provider<PushRegistrar> pushRegistrarProvider = Provider<PushRegistrar>(
(Ref ref) => const NoopPushRegistrar(),
);
+51
View File
@@ -0,0 +1,51 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다.
///
/// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림").
/// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다.
class PushTarget {
const PushTarget({required this.module, this.itemId});
/// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null.
static PushTarget? fromData(Map<String, Object?> data) {
final Object? name = data[moduleKey];
final AppModuleId? module = AppModuleId.fromName(
name is String ? name : null,
);
if (module == null) return null;
final Object? id = data[itemIdKey];
return PushTarget(
module: module,
itemId: id is String && id.isNotEmpty ? id : null,
);
}
/// 알림 데이터의 칸 이름 — 서버(푸시 보내는 쪽)와 짝이다
static const String moduleKey = 'module';
static const String itemIdKey = 'id';
final AppModuleId module;
/// 모듈 안의 항목 id (메일 id 등) — 없으면 모듈 첫 화면
final String? itemId;
}
/// 알림을 눌렀을 때 갈 경로 — 꺼졌거나 라우트가 없는 모듈이면 홈(메일 첫 화면).
///
/// [routesOf] 는 모듈 id → 라우트 묶음이다(core/router/module_registry.dart 의 moduleRoutesOf).
String locationForPush(
PushTarget? target,
ModuleRoutes? Function(AppModuleId id) routesOf, {
List<AppModule> modules = kAppModules,
}) {
if (target == null) return homeLocationOf(modules);
final AppModule module = findAppModule(target.module, modules);
final ModuleRoutes? routes = routesOf(target.module);
if (!module.isEnabled || routes == null) return homeLocationOf(modules);
final String? itemId = target.itemId;
final String Function(String itemId)? locationOfItem = routes.locationOfItem;
if (itemId == null || locationOfItem == null) return module.rootPath;
return locationOfItem(itemId);
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/core/router/app_shell.dart';
import 'package:arasystem_app/core/router/module_registry.dart';
import 'package:arasystem_app/core/router/session_redirect.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 켜는 모듈 목록 — 시험은 다른 목록으로 덮어쓴다
final Provider<List<AppModule>> appModulesProvider = Provider<List<AppModule>>(
(Ref ref) => kAppModules,
);
/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다.
///
/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다.
/// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다).
/// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다.
/// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException).
///
/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면
/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다).
final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
final List<AppModule> modules = ref.watch(appModulesProvider);
final List<AppModule> enabled = enabledAppModules(modules);
final List<ModuleRoutes> moduleRoutes = enabledModuleRoutes(modules);
final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
final ModuleRouteContext routeContext = ModuleRouteContext(
rootNavigatorKey: rootNavigatorKey,
settingsSections: moduleSettingsSections(moduleRoutes),
);
final _SessionChanges sessionChanges = _SessionChanges();
ref.listen<AsyncValue<SessionState>>(
sessionProvider,
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
sessionChanges.notify(),
);
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.start,
refreshListenable: sessionChanges,
redirect: (BuildContext context, GoRouterState state) =>
resolveSessionRedirect(
ref.read(sessionProvider),
state.uri.path,
modules: modules,
),
// 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로
onException: (BuildContext context, GoRouterState state, GoRouter router) =>
router.go(homeLocationOf(modules)),
routes: <RouteBase>[
_placeholderRoute(AppRoutes.start, ScreenTitles.start),
_placeholderRoute(AppRoutes.login, ScreenTitles.login),
_placeholderRoute(AppRoutes.loginCode, ScreenTitles.loginCode),
_placeholderRoute(AppRoutes.passwordChange, ScreenTitles.passwordChange),
_placeholderRoute(AppRoutes.twoFactorSetup, ScreenTitles.twoFactorSetup),
_placeholderRoute(AppRoutes.restricted, ScreenTitles.restricted),
StatefulShellRoute.indexedStack(
builder:
(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) => AppShell(navigationShell: navigationShell, modules: enabled),
branches: <StatefulShellBranch>[
for (final ModuleRoutes module in moduleRoutes)
StatefulShellBranch(routes: module.buildRoutes(routeContext)),
],
),
],
);
ref.onDispose(() {
router.dispose();
sessionChanges.dispose();
});
return router;
});
GoRoute _placeholderRoute(String path, String title) {
return GoRoute(
path: path,
builder: (BuildContext context, GoRouterState state) =>
PlaceholderPage(title: title),
);
}
/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable)
class _SessionChanges extends ChangeNotifier {
void notify() => notifyListeners();
}
+23
View File
@@ -0,0 +1,23 @@
/// 로그인 전 · 제한 단계 화면의 경로 — 탭 막대 밖(셸 밖)에 둔다.
///
/// 로그인 뒤 화면은 모듈 이름부터 시작한다(`/mail/...` · `/settings/...` — core/modules/app_modules.dart,
/// 경로 상수는 모듈마다 `features/<모듈>/<모듈>_routes.dart`). redirect 가 이 구분으로 화면을 고른다(session_redirect.dart).
abstract final class AppRoutes {
/// 세션을 되살리는 동안 · 되살리기가 장애로 실패했을 때
static const String start = '/start';
/// 로그인 — 비밀번호 단계
static const String login = '/login';
/// 로그인 — 2단계 인증 코드 단계
static const String loginCode = '/login/code';
/// 제한된 세션 — 새 비밀번호 정하기
static const String passwordChange = '/login/password';
/// 제한된 세션 — 2단계 인증 켜기
static const String twoFactorSetup = '/login/two-factor';
/// 제한된 세션 — 앱이 모르는 제한
static const String restricted = '/login/restricted';
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다.
///
/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack).
/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다.
/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다.
class AppShell extends StatelessWidget {
const AppShell({
required this.navigationShell,
required this.modules,
super.key,
});
final StatefulNavigationShell navigationShell;
/// 켜진 모듈 — 탭 하나씩 (브랜치 차례와 같다)
final List<AppModule> modules;
/// NavigationBar 는 탭이 둘 이상이어야 한다
static const int _minTabs = 2;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: modules.length < _minTabs
? null
: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (int index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
destinations: <Widget>[
for (final AppModule module in modules)
NavigationDestination(
icon: Icon(module.icon),
selectedIcon: Icon(module.selectedIcon),
label: module.label,
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/settings/settings_routes.dart';
/// 모듈 id → 그 모듈의 라우트 묶음. 아직 만들지 않은 모듈은 null.
///
/// 모듈을 켜면(app_modules.dart 의 `isEnabled`) 여기에 묶음을 적는다 — 켜진 모듈에 묶음이 없으면
/// 라우터를 만들 때 멈춘다([enabledModuleRoutes]). enum 의 모든 값을 다뤄야 해서 새 모듈 id 도 빠뜨릴 수 없다.
ModuleRoutes? moduleRoutesOf(AppModuleId id) {
return switch (id) {
AppModuleId.mail => mailModuleRoutes,
AppModuleId.settings => settingsModuleRoutes,
AppModuleId.calendar => null,
AppModuleId.files => null,
AppModuleId.contacts => null,
};
}
/// 켜진 모듈의 라우트 묶음 — 탭 막대 차례 그대로
List<ModuleRoutes> enabledModuleRoutes([
List<AppModule> modules = kAppModules,
]) {
return <ModuleRoutes>[
for (final AppModule module in enabledAppModules(modules))
moduleRoutesOf(module.id) ??
(throw StateError('켜진 모듈 ${module.id.name} 의 라우트 묶음이 없다')),
];
}
/// 모듈들이 내놓은 설정 절 — 설정 모듈이 자기 라우트 아래에 붙인다
List<ModuleSettingsSection> moduleSettingsSections(List<ModuleRoutes> routes) {
return <ModuleSettingsSection>[
for (final ModuleRoutes module in routes) ...module.settingsSections,
];
}
@@ -0,0 +1,58 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 세션 상태로 갈 화면을 고른다 — 라우터의 redirect 하나가 이것만 부른다(웹 `router/index.ts` 가드와 같은 결).
///
/// 돌려주는 값: 옮길 경로, 지금 화면에 그대로 있으면 null.
///
/// | 세션 | 화면 |
/// | --- | --- |
/// | 되살리는 중 · 되살리기 실패 | 시작 |
/// | 로그아웃 | 로그인 |
/// | 코드 단계 | 코드 |
/// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 |
/// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 |
String? resolveSessionRedirect(
AsyncValue<SessionState> session,
String location, {
List<AppModule> modules = kAppModules,
}) {
final SessionState? state = session.value;
if (state == null) return _stayOr(location, AppRoutes.start);
switch (state) {
case SignedOut():
return _stayOr(location, AppRoutes.login);
case CodeRequired():
return _stayOr(location, AppRoutes.loginCode);
case SignedIn(:final AuthSessionInfo info):
return switch (info.restriction) {
SessionRestriction.passwordChange => _stayOr(
location,
AppRoutes.passwordChange,
),
SessionRestriction.twoFactorSetup => _stayOr(
location,
AppRoutes.twoFactorSetup,
),
SessionRestriction.unknown => _stayOr(location, AppRoutes.restricted),
null =>
_isSignedOutArea(location) ||
enabledModuleOfPath(location, modules) == null
? homeLocationOf(modules)
: null,
};
}
}
/// 로그인하지 않은 동안만 머무는 화면(시작 · 로그인 · 제한 단계)인가
bool _isSignedOutArea(String location) =>
location == AppRoutes.start ||
location == AppRoutes.login ||
location.startsWith('${AppRoutes.login}/');
String? _stayOr(String location, String target) =>
location == target ? null : target;
+53
View File
@@ -0,0 +1,53 @@
/// 앱 로그인 토큰 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"의 `tokens` 칸
/// (`{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`, 시각은 ISO 8601 UTC).
///
/// 토큰 값은 로그 · 예외 문장에 남기지 않는다 — [toString] 도 값을 가린다.
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.accessExpiresAt,
required this.refreshToken,
required this.refreshExpiresAt,
this.serverTime,
});
final String accessToken;
final DateTime accessExpiresAt;
final String refreshToken;
final DateTime refreshExpiresAt;
/// 토큰을 실어 온 응답의 `Date` 머리(서버 시각) — 남은 시간을 서버 시계로 센다(기기 시계가 틀려도).
/// 응답에 없으면 null — 그때는 기기 시계와 견준다.
final DateTime? serverTime;
/// 응답의 `tokens` 칸을 읽는다. 없거나(`null` — 코드 단계 · 갱신 유예) 모양이 틀리면 null.
static AuthTokens? tryParse(Object? json, {DateTime? serverTime}) {
if (json is! Map<String, Object?>) return null;
final Object? access = json['accessToken'];
final Object? refresh = json['refreshToken'];
final DateTime? accessExpiresAt = _parseTime(json['accessExpiresAt']);
final DateTime? refreshExpiresAt = _parseTime(json['refreshExpiresAt']);
if (access is! String || access.isEmpty) return null;
if (refresh is! String || refresh.isEmpty) return null;
if (accessExpiresAt == null || refreshExpiresAt == null) return null;
return AuthTokens(
accessToken: access,
accessExpiresAt: accessExpiresAt,
refreshToken: refresh,
refreshExpiresAt: refreshExpiresAt,
serverTime: serverTime?.toUtc(),
);
}
/// 액세스 토큰을 쓸 수 있는 시간 — 서버 시계로 센 "받은 때부터 만료까지" (없으면 [fallbackNow] 와 견준다)
Duration accessLifetime(DateTime fallbackNow) =>
accessExpiresAt.difference(serverTime ?? fallbackNow);
static DateTime? _parseTime(Object? value) {
if (value is! String) return null;
return DateTime.tryParse(value)?.toUtc();
}
@override
String toString() => 'AuthTokens(***)';
}
@@ -0,0 +1,28 @@
import 'package:shared_preferences/shared_preferences.dart';
/// 첫 실행 표시 — 앱을 지우면 함께 지워지는 일반 저장소에 둔다.
///
/// iOS Keychain 은 앱을 지워도 남는다. 다시 깐 앱의 첫 실행에 이 표시가 없으면
/// 보안 저장소에 남은 토큰을 지운다(TokenStore.prepare — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일").
/// Android 는 백업을 꺼서(AndroidManifest) 이 표시가 다른 기기로 복원되지 않는다.
abstract interface class FirstRunMarker {
Future<bool> isMarked();
Future<void> mark();
}
/// `shared_preferences` 구현 — 값은 참 · 거짓 하나뿐이다(토큰 · 개인 정보는 두지 않는다)
class PrefsFirstRunMarker implements FirstRunMarker {
PrefsFirstRunMarker([SharedPreferencesAsync? prefs])
: _prefs = prefs ?? SharedPreferencesAsync();
/// 저장 키
static const String key = 'first_run_done';
final SharedPreferencesAsync _prefs;
@override
Future<bool> isMarked() async => await _prefs.getBool(key) ?? false;
@override
Future<void> mark() => _prefs.setBool(key, true);
}
@@ -0,0 +1,43 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 보안 저장소(Android Keystore · iOS Keychain)의 좁은 창구 — 토큰 보관이 쓰는 것만 둔다.
///
/// 시험에서는 메모리 구현으로 바꿔 끼운다. 토큰은 이것으로만 디스크에 둔다
/// (`SharedPreferences` 에 토큰 저장 금지 — 보안 규칙).
abstract interface class SecureStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
/// 이 앱이 둔 값을 모두 지운다 — 첫 실행에 남은 값 지우기
Future<void> deleteAll();
}
/// `flutter_secure_storage` 구현
class FlutterSecureStore implements SecureStore {
const FlutterSecureStore([
this._storage = const FlutterSecureStorage(
// 기본: RSA OAEP 로 감싼 AES-GCM (Android 6.0+). 생체 인증은 쓰지 않는다
aOptions: AndroidOptions(),
// 기기를 처음 잠금 해제한 뒤에만 읽는다 · 다른 기기로 옮겨지지 않는다(iCloud 키체인 · 백업 제외)
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
]);
final FlutterSecureStorage _storage;
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
@override
Future<void> delete(String key) => _storage.delete(key: key);
@override
Future<void> deleteAll() => _storage.deleteAll();
}
+201
View File
@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:flutter/services.dart';
/// 리프레시 토큰 저장 키 — 앱 전체에서 이 상수만 쓴다
const String kRefreshTokenKey = 'refresh_token';
/// 액세스 토큰 만료 이만큼 전부터는 미리 갱신한다
const Duration kAccessTokenRefreshMargin = Duration(seconds: 30);
/// 끝난 세션의 까닭을 기억해 두는 수 — 늦게 돌아온 응답이 어느 세션 것인지 가를 만큼만
const int _kEndedSessionMemory = 16;
/// 로그아웃할 때 서버 세션을 끝내는 데 쓸 토큰 (읽지 못했으면 null)
typedef SignOutTokens = ({String? refreshToken, String? accessToken});
/// 앱 로그인 토큰 보관 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일".
///
/// - 리프레시 토큰은 보안 저장소([SecureStore])에만, 액세스 토큰은 메모리에만 둔다.
/// - 새 리프레시 토큰은 받자마자 저장한다 — 서버는 1회용으로 회전하므로, 옛 토큰이 남으면
/// 다음 실행의 갱신이 "이미 쓴 토큰"으로 세션을 끊는다(회전 직후 앱이 죽는 경우는 알려진 한계).
/// 저장이 실패하면 이번 실행 동안 메모리의 새 토큰으로 이어 가고 다음 저장 · 읽기 때 다시 쓴다 — 그 전에 앱이 죽으면 같은 한계다.
/// - 보안 저장소의 쓰기 · 지우기 · 읽기는 부른 차례대로 하나씩 한다 — 로그인 저장과 로그아웃 지우기가 엇갈려 끝난 세션의 토큰이 남지 않게.
/// - 만료는 기기 시계로 판단하지 않는다 — 받은 응답의 `Date`(서버 시각)로 남은 시간을 재고, 받은 때부터 지난 시간을 센다.
/// 기기 시계가 몇 시간 틀려도 매 요청 갱신하거나 끝난 토큰을 쓰지 않는다(시계를 그사이 바꾸면 401 뒤 갱신이 받쳐 준다).
/// - [generation](토큰 세대)은 토큰이 바뀔 때마다, [sessionGeneration](세션 세대)은 세션이 끝날 때만 오른다 —
/// "다른 요청이 이미 갱신했다"와 "그 요청을 보낸 세션이 끝났다(로그아웃 → 다른 직원 로그인)"를 가른다(core/api/auth_interceptor.dart).
class TokenStore {
TokenStore({
required SecureStore secure,
required FirstRunMarker firstRun,
Clock clock = systemClock,
}) : _secure = secure,
_firstRun = firstRun,
_clock = clock;
final SecureStore _secure;
final FirstRunMarker _firstRun;
final Clock _clock;
String? _accessToken;
Duration? _accessLifetime;
DateTime? _accessReceivedAt;
String? _unsavedRefreshToken;
bool _hasRefreshToken = false;
bool _signedOutByUser = false;
int _generation = 0;
int _sessionGeneration = 0;
final Map<int, bool> _endedByUser = <int, bool>{};
Future<void> _storage = Future<void>.value();
/// 앱을 띄우기 전에 한 번 부른다.
///
/// 첫 실행 표시가 없으면(새로 깔았거나 다시 깔았다) 보안 저장소에 남은 값을 모두 지운다 —
/// iOS Keychain 은 앱을 지워도 남기 때문이다. 저장소를 읽지 못해도 로그아웃 상태로 시작한다.
Future<void> prepare() {
return _serial(() async {
try {
if (!await _firstRun.isMarked()) {
await _secure.deleteAll();
await _firstRun.mark();
_hasRefreshToken = false;
return;
}
_hasRefreshToken = await _secure.read(kRefreshTokenKey) != null;
} on PlatformException {
// 키가 바뀌었거나 저장소가 깨졌다 — 다시 로그인하면 새로 쓴다
_hasRefreshToken = false;
}
});
}
/// 리프레시 토큰을 갖고 있는가 — 로그인한 세션이 있다(서버에서 끝났을 수는 있다)
bool get hasSession => _hasRefreshToken;
/// 사용자가 스스로 로그아웃했다(다음 로그인까지)
bool get signedOutByUser => _signedOutByUser;
/// 메모리의 액세스 토큰 (앱을 새로 띄우면 없다)
String? get accessToken => _accessToken;
/// 액세스 토큰이 있고, 받은 때부터 지난 시간이 쓸 수 있는 시간보다 [kAccessTokenRefreshMargin] 넘게 적은가
bool get hasUsableAccessToken {
final Duration? lifetime = _accessLifetime;
final DateTime? receivedAt = _accessReceivedAt;
if (_accessToken == null || lifetime == null || receivedAt == null) {
return false;
}
final Duration elapsed = _clock().difference(receivedAt);
// 기기 시계를 뒤로 돌렸다 — 지난 시간을 모르니 미리 갱신한다
if (elapsed.isNegative) return false;
return elapsed < lifetime - kAccessTokenRefreshMargin;
}
/// 토큰 세대 — 저장 · 지우기마다 하나씩 오른다
int get generation => _generation;
/// 세션 세대 — 세션이 끝날 때(지우기 · 로그아웃)만 오른다. 새 로그인은 올리지 않는다
int get sessionGeneration => _sessionGeneration;
/// [sessionGeneration] 의 세션이 끝났다면 사용자가 스스로 끝냈는가 — 끝나지 않았거나 모르면 null
bool? endedByUser(int sessionGeneration) => _endedByUser[sessionGeneration];
/// 리프레시 토큰 — 저장하지 못한 새 토큰이 있으면 그것(이번에 다시 써 본다), 아니면 보안 저장소의 것
Future<String?> readRefreshToken() {
return _serial(() async {
final String? unsaved = _unsavedRefreshToken;
if (unsaved != null) {
await _writeUnsaved(unsaved);
return unsaved;
}
return _secure.read(kRefreshTokenKey);
});
}
/// 새 토큰을 둔다 — 메모리는 곧바로 바꾸고, 리프레시 토큰은 보안 저장소에 쓴 뒤에 돌아온다.
///
/// 쓰기가 실패하면 `PlatformException` 을 던지되 메모리의 토큰은 남긴다 — 부른 쪽이 정한다
/// (로그인은 그 세션을 끝내고, 갱신은 이번 실행 동안 메모리로 이어 간다).
Future<void> save(AuthTokens tokens) {
final DateTime now = _clock();
_generation++;
_accessToken = tokens.accessToken;
_accessLifetime = tokens.accessLifetime(now);
_accessReceivedAt = now;
_unsavedRefreshToken = tokens.refreshToken;
_hasRefreshToken = true;
_signedOutByUser = false;
final String refreshToken = tokens.refreshToken;
return _serial(() async {
// 그사이 지웠거나 더 새 토큰을 받았으면 이 토큰은 쓰지 않는다
if (_unsavedRefreshToken != refreshToken) return;
await _secure.write(kRefreshTokenKey, refreshToken);
if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null;
});
}
/// 토큰을 모두 지운다 — 세션 끝 · 로그인 저장 실패. 메모리는 곧바로 비운다(보안 저장소를 지우다 실패해도).
Future<void> clear({bool byUser = false}) {
_endSession(byUser: byUser);
return _serial(() => _secure.delete(kRefreshTokenKey));
}
/// 로그아웃 — 메모리를 곧바로 비우고(이 기기는 그 순간 로그아웃이다) 보안 저장소의 리프레시 토큰을 읽어 지운다.
/// 서버 세션을 끝내는 데 쓸 토큰을 돌려준다. 저장소를 읽거나 지우지 못해도 던지지 않는다.
Future<SignOutTokens> signOut() {
final String? accessToken = _accessToken;
final String? unsaved = _unsavedRefreshToken;
_endSession(byUser: true);
return _serial(() async {
// 로그아웃은 어떤 실패에도 끝나야 한다 — 저장소의 뜻밖의 예외도 삼킨다
String? refreshToken = unsaved;
try {
refreshToken ??= await _secure.read(kRefreshTokenKey);
} on Object {
refreshToken = null;
}
try {
await _secure.delete(kRefreshTokenKey);
} on Object {
// 남은 값은 다음 실행의 갱신이 거절되면 지운다
}
return (refreshToken: refreshToken, accessToken: accessToken);
});
}
void _endSession({required bool byUser}) {
_generation++;
_endedByUser[_sessionGeneration] = byUser;
if (_endedByUser.length > _kEndedSessionMemory) {
_endedByUser.remove(_endedByUser.keys.first);
}
_sessionGeneration++;
_accessToken = null;
_accessLifetime = null;
_accessReceivedAt = null;
_unsavedRefreshToken = null;
_hasRefreshToken = false;
_signedOutByUser = byUser;
}
Future<void> _writeUnsaved(String refreshToken) async {
try {
await _secure.write(kRefreshTokenKey, refreshToken);
if (_unsavedRefreshToken == refreshToken) _unsavedRefreshToken = null;
} on PlatformException {
// 이번에도 쓰지 못했다 — 메모리의 토큰으로 이어 간다
}
}
/// 보안 저장소 일을 부른 차례대로 하나씩 한다 — 앞 일의 실패는 뒤 일에 옮기지 않는다
Future<T> _serial<T>(Future<T> Function() operation) {
final Future<T> result = _storage.then((_) => operation());
_storage = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
}
@@ -0,0 +1,12 @@
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 토큰 보관 — main 이 [TokenStore.prepare] 를 마친 것으로 덮어쓴다
/// (`ProviderScope(overrides: [tokenStoreProvider.overrideWithValue(store)])`).
///
/// 준비(첫 실행 지우기)를 마치기 전에 토큰을 읽지 않게, 덮지 않으면 일부러 실패한다.
final Provider<TokenStore> tokenStoreProvider = Provider<TokenStore>(
(Ref ref) => throw UnimplementedError(
'tokenStoreProvider 는 main 에서 준비한 TokenStore 로 덮어써야 한다',
),
);
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/painting.dart';
/// 색 — 웹 `frontend/src/styles/tokens.css` 의 색을 그대로 옮긴다 (이름도 토큰 이름을 따른다).
///
/// 화면은 이 상수를 직접 쓰지 않고 `Theme.of(context).colorScheme` · [AppExtraColors] 로 쓴다.
/// 웹 토큰을 바꾸면 여기도 함께 바꾼다 — 대응은 `app_theme.dart` 의 `_colorScheme` 한 곳이다.
abstract final class AppColors {
// ── 브랜드 ──
/// `--color-brand-surface` — 바탕(레일 · 로그인 패널 · 성공 알림)
static const Color brandSurface = Color(0xFF0B3640);
/// `--color-brand-surface-active`
static const Color brandSurfaceActive = Color(0xFF155A64);
/// `--color-brand` — 강조 단추 · 링크 · 진행률
static const Color brand = Color(0xFF0E7C86);
/// `--color-brand-strong` — 강조 글자 · 눌린 상태
static const Color brandStrong = Color(0xFF0A5F67);
/// `--color-brand-subtle` — 고른 행 · 지금 메일함 · 아바타
static const Color brandSubtle = Color(0xFFE3F2F3);
/// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕
static const Color brandTint = Color(0xFFF3F8F8);
/// `--color-on-brand`
static const Color onBrand = Color(0xFFFFFFFF);
/// `--color-on-brand-text` — 브랜드 바탕 위 글자
static const Color onBrandText = Color(0xFFE6EFF0);
/// `--color-on-brand-muted`
static const Color onBrandMuted = Color(0xFFA3C2C6);
/// `--color-on-brand-link` — 브랜드 바탕 위 링크 · 알림 안 단추
static const Color onBrandLink = Color(0xFF9ED6DB);
// ── 글자 ──
/// `--color-text`
static const Color text = Color(0xFF171C26);
/// `--color-text-body` — 메일 본문
static const Color textBody = Color(0xFF1F2733);
/// `--color-text-strong` — 입력 이름 · 도구 단추
static const Color textStrong = Color(0xFF344054);
/// `--color-text-secondary`
static const Color textSecondary = Color(0xFF4A5363);
/// `--color-text-muted`
static const Color textMuted = Color(0xFF667085);
/// `--color-icon-muted`
static const Color iconMuted = Color(0xFF98A2B3);
// ── 면 · 선 ──
/// `--color-surface`
static const Color surface = Color(0xFFFFFFFF);
/// `--color-surface-panel` — 메일함 패널
static const Color surfacePanel = Color(0xFFF8F9FB);
/// `--color-surface-muted` — 받는 사람 토큰 · 파일 아이콘
static const Color surfaceMuted = Color(0xFFF2F4F7);
/// `--color-surface-sunken` — 목록 머리 줄
static const Color surfaceSunken = Color(0xFFFBFBFC);
/// `--color-border`
static const Color border = Color(0xFFE6E8EC);
/// `--color-border-subtle`
static const Color borderSubtle = Color(0xFFEEF0F3);
/// `--color-border-strong`
static const Color borderStrong = Color(0xFFD0D5DD);
/// `--color-border-input` — 입력 칸 테두리 (흰 바탕 3:1 이상)
static const Color borderInput = Color(0xFF8A94A3);
/// `--color-skeleton` — 불러오는 중 막대 · 진행률 바탕
static const Color skeleton = Color(0xFFE4E7EC);
// ── 상태 ──
/// `--color-danger`
static const Color danger = Color(0xFFB42318);
/// `--color-danger-strong`
static const Color dangerStrong = Color(0xFF912018);
/// `--color-danger-surface`
static const Color dangerSurface = Color(0xFFFEF3F2);
/// `--color-warning-surface`
static const Color warningSurface = Color(0xFFFFF7E6);
/// `--color-warning-text`
static const Color warningText = Color(0xFF7A5200);
/// `--color-warning-border`
static const Color warningBorder = Color(0xFFF0D9A8);
/// `--color-success-text`
static const Color successText = Color(0xFF067647);
/// `--color-star` — 중요 표시
static const Color star = Color(0xFFB7791F);
/// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …)
static const Color shadow = Color(0xFF10181C);
/// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막
static const Color backdrop = Color(0x520B3640);
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// 앱 글꼴 — Pretendard 1.3.9 정적 OTF 넷(Regular · Medium · SemiBold · Bold — pubspec `fonts`).
///
/// SIL OFL 1.1 이다. 앱과 함께 나눌 때 저작권 표시와 라이선스 글을 함께 둬야 해서
/// (OFL 2조) [registerFontLicenses] 가 Flutter 라이선스 화면(`showLicensePage`)에 올린다.
abstract final class AppFonts {
/// pubspec 의 `family` 와 같아야 한다
static const String family = 'Pretendard';
/// 라이선스 글 — npm `pretendard` 1.3.9 의 `dist/LICENSE.txt` 를 그대로 옮겼다 (pubspec `assets`)
static const String licenseAsset = 'assets/licenses/pretendard-ofl.txt';
/// 라이선스 화면에 보일 이름
static const String licensePackageName = 'Pretendard';
/// 글꼴 라이선스를 라이선스 화면에 올린다 — 앱을 띄우기 전에 한 번 부른다.
///
/// 글은 라이선스 화면을 열 때에만 읽는다(LicenseRegistry 가 늦게 부른다).
static void registerFontLicenses({AssetBundle? bundle}) {
final AssetBundle source = bundle ?? rootBundle;
LicenseRegistry.addLicense(() async* {
final String text = await source.loadString(licenseAsset);
yield LicenseEntryWithLineBreaks(<String>[licensePackageName], text);
});
}
}
+55
View File
@@ -0,0 +1,55 @@
/// 간격 · 모서리 · 크기 — 웹 `tokens.css` 의 `--space-*` · `--radius-*` 를 dp 로 옮긴다 (이름의 숫자 = dp).
///
/// 화면은 숫자를 직접 적지 않고 이 상수를 쓴다. 휴대폰에서 쓰지 않을 값은 옮기지 않았다 —
/// 캔버스에서 새 간격이 정해지면 여기에 더한다.
library;
/// 간격 (웹 `--space-N` 과 같은 이름 · 같은 값)
abstract final class AppSpacing {
static const double s2 = 2;
static const double s4 = 4;
static const double s6 = 6;
static const double s8 = 8;
static const double s10 = 10;
static const double s12 = 12;
static const double s14 = 14;
static const double s16 = 16;
static const double s20 = 20;
static const double s24 = 24;
static const double s28 = 28;
static const double s36 = 36;
static const double s40 = 40;
static const double s48 = 48;
static const double s56 = 56;
}
/// 모서리 반지름 (웹 `--radius-*`)
abstract final class AppRadius {
/// `--radius-xs` — 받는 사람 토큰
static const double xs = 4;
/// `--radius-sm`
static const double sm = 6;
/// `--radius-md` — 단추 · 입력
static const double md = 8;
/// `--radius-lg` — 알림
static const double lg = 10;
/// `--radius-xl` — 대화상자
static const double xl = 12;
}
/// 크기
abstract final class AppSize {
/// 누를 수 있는 곳의 최소 크기 — Material 기준 48dp (웹 `--size-touch` 44px 보다 크다).
/// 웹의 "밀도 예외"는 앱에 옮기지 않는다 — 캔버스에서 따로 정한다.
static const double touchMin = 48;
/// 선 두께 — `--border-width` 1px
static const double borderWidth = 1;
/// 초점 고리 두께 — `--focus-ring-width` 2px
static const double focusRingWidth = 2;
}
+234
View File
@@ -0,0 +1,234 @@
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:flutter/material.dart';
/// 앱 테마 — 밝은 테마 하나(다크 모드는 첫 판 밖).
///
/// 색은 시드 생성(`ColorScheme.fromSeed`)을 쓰지 않고 웹 토큰을 역할마다 손으로 대응한다 —
/// 시드는 브랜드 청록과 다른 색을 만들어 웹 화면과 어긋난다.
abstract final class AppTheme {
static ThemeData light() {
const ColorScheme scheme = _colorScheme;
final TextTheme textTheme = AppTypography.textTheme();
final RoundedRectangleBorder controlShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
);
const Size touchSize = Size(AppSize.touchMin, AppSize.touchMin);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: AppFonts.family,
textTheme: textTheme,
scaffoldBackgroundColor: scheme.surface,
// 누르는 곳은 늘 48dp 이상 — 작은 단추도 누르는 영역을 48 로 넓힌다
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard,
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: AppSize.borderWidth,
space: AppSize.borderWidth,
),
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: textTheme.titleMedium?.copyWith(
color: scheme.onSurface,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
textStyle: textTheme.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
side: const BorderSide(color: AppColors.borderStrong),
textStyle: textTheme.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: touchSize,
shape: controlShape,
textStyle: textTheme.labelLarge,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(minimumSize: touchSize),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
vertical: AppSpacing.s14,
),
border: _inputBorder(AppColors.borderInput),
enabledBorder: _inputBorder(AppColors.borderInput),
focusedBorder: _inputBorder(
scheme.primary,
width: AppSize.focusRingWidth,
),
errorBorder: _inputBorder(scheme.error),
focusedErrorBorder: _inputBorder(
scheme.error,
width: AppSize.focusRingWidth,
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: scheme.inverseSurface,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: scheme.onInverseSurface,
),
actionTextColor: scheme.inversePrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
extensions: const <AppExtraColors>[AppExtraColors.light],
);
}
static OutlineInputBorder _inputBorder(
Color color, {
double width = AppSize.borderWidth,
}) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
borderSide: BorderSide(color: color, width: width),
);
}
/// 웹 토큰 → Material 3 색 역할 (대응을 바꾸면 웹 화면과 견주어 본다)
static const ColorScheme _colorScheme = ColorScheme(
brightness: Brightness.light,
// 강조 단추 · 링크 · 진행률
primary: AppColors.brand,
onPrimary: AppColors.onBrand,
// 고른 행 · 지금 메일함 — 그 위 글자는 강조 글자
primaryContainer: AppColors.brandSubtle,
onPrimaryContainer: AppColors.brandStrong,
// 브랜드 바탕(로그인 패널 · 성공 알림)
secondary: AppColors.brandSurface,
onSecondary: AppColors.onBrandText,
secondaryContainer: AppColors.brandTint,
onSecondaryContainer: AppColors.text,
tertiary: AppColors.brandSurfaceActive,
onTertiary: AppColors.onBrand,
error: AppColors.danger,
onError: AppColors.onBrand,
errorContainer: AppColors.dangerSurface,
onErrorContainer: AppColors.dangerStrong,
surface: AppColors.surface,
onSurface: AppColors.text,
onSurfaceVariant: AppColors.textSecondary,
surfaceContainerLowest: AppColors.surface,
surfaceContainerLow: AppColors.surfaceSunken,
surfaceContainer: AppColors.surfacePanel,
surfaceContainerHigh: AppColors.surfaceMuted,
surfaceContainerHighest: AppColors.skeleton,
// 입력 칸 테두리(3:1) · 나눔선
outline: AppColors.borderInput,
outlineVariant: AppColors.border,
shadow: AppColors.shadow,
scrim: AppColors.backdrop,
// 스낵바(알림) — 웹 알림처럼 브랜드 바탕에 밝은 글자
inverseSurface: AppColors.brandSurface,
onInverseSurface: AppColors.onBrandText,
inversePrimary: AppColors.onBrandLink,
// 높이에 따라 면에 색을 섞지 않는다 — 웹처럼 흰 면 그대로
surfaceTint: AppColors.surface,
);
}
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
@immutable
class AppExtraColors extends ThemeExtension<AppExtraColors> {
const AppExtraColors({
required this.textMuted,
required this.textBody,
required this.iconMuted,
required this.warningSurface,
required this.warningText,
required this.warningBorder,
required this.successText,
required this.star,
});
static const AppExtraColors light = AppExtraColors(
textMuted: AppColors.textMuted,
textBody: AppColors.textBody,
iconMuted: AppColors.iconMuted,
warningSurface: AppColors.warningSurface,
warningText: AppColors.warningText,
warningBorder: AppColors.warningBorder,
successText: AppColors.successText,
star: AppColors.star,
);
/// 시각 · 개수 · 보조 설명
final Color textMuted;
/// 메일 본문
final Color textBody;
final Color iconMuted;
/// 노란 안내 줄
final Color warningSurface;
final Color warningText;
final Color warningBorder;
final Color successText;
/// 중요 표시
final Color star;
@override
AppExtraColors copyWith({
Color? textMuted,
Color? textBody,
Color? iconMuted,
Color? warningSurface,
Color? warningText,
Color? warningBorder,
Color? successText,
Color? star,
}) {
return AppExtraColors(
textMuted: textMuted ?? this.textMuted,
textBody: textBody ?? this.textBody,
iconMuted: iconMuted ?? this.iconMuted,
warningSurface: warningSurface ?? this.warningSurface,
warningText: warningText ?? this.warningText,
warningBorder: warningBorder ?? this.warningBorder,
successText: successText ?? this.successText,
star: star ?? this.star,
);
}
@override
AppExtraColors lerp(ThemeExtension<AppExtraColors>? other, double t) {
if (other is! AppExtraColors) return this;
return AppExtraColors(
textMuted: Color.lerp(textMuted, other.textMuted, t)!,
textBody: Color.lerp(textBody, other.textBody, t)!,
iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!,
warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!,
warningText: Color.lerp(warningText, other.warningText, t)!,
warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!,
successText: Color.lerp(successText, other.successText, t)!,
star: Color.lerp(star, other.star, t)!,
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:flutter/material.dart';
/// 글자 크기 · 굵기 · 줄 높이 — 바꿀 때는 이 파일 한 곳만 고친다.
///
/// 정한 값(`docs/design/app/README.md` "크기 · 글꼴" — 2026-11-05 확정): 화면 글 15 · 입력 칸 16(`bodyLarge` — TextField 의 기본)
/// · 메일 본문 15 · 줄 간격 1.75([lineHeightBody] — 본문은 WebView 가 그린다) · 위 막대 제목 18(`titleMedium`).
/// 그 밖의 역할(제목 · 꼬리표)은 **임시 값**이다 — 화면 작업 때 보드와 견주어 정한다.
/// 화면은 숫자를 적지 않고 `Theme.of(context).textTheme` 의 역할 이름으로만 쓴다.
abstract final class AppTypography {
// ── 크기 (sp — 기기 글자 크기 설정을 따라 커진다) ──
static const double display = 28;
static const double headline = 22;
static const double titleLarge = 20;
/// 위 막대 제목 (확정)
static const double titleMedium = 18;
static const double titleSmall = 15;
/// 입력 칸 (확정)
static const double bodyLarge = 16;
/// 화면 글 · 메일 본문 (확정)
static const double bodyMedium = 15;
static const double bodySmall = 13;
static const double labelLarge = 15;
static const double labelMedium = 13;
static const double labelSmall = 12;
// ── 줄 높이 (웹 `--line-height-*`) ──
static const double lineHeightBase = 1.5;
static const double lineHeightHeading = 1.4;
static const double lineHeightTight = 1.3;
/// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정)
static const double lineHeightBody = 1.75;
// ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ──
static const double letterSpacingTightEm = -0.01;
static const double letterSpacingHeadingEm = -0.02;
// ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ──
static const FontWeight regular = FontWeight.w400;
static const FontWeight medium = FontWeight.w500;
static const FontWeight semiBold = FontWeight.w600;
static const FontWeight bold = FontWeight.w700;
/// 앱의 글자 체계 — 색은 테마가 입힌다 (`ThemeData.textTheme` 에 색을 넣지 않는다)
static TextTheme textTheme() {
return const TextTheme(
displaySmall: TextStyle(
fontSize: display,
fontWeight: bold,
height: lineHeightTight,
letterSpacing: display * letterSpacingHeadingEm,
),
headlineSmall: TextStyle(
fontSize: headline,
fontWeight: bold,
height: lineHeightHeading,
letterSpacing: headline * letterSpacingHeadingEm,
),
titleLarge: TextStyle(
fontSize: titleLarge,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleLarge * letterSpacingHeadingEm,
),
titleMedium: TextStyle(
fontSize: titleMedium,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleMedium * letterSpacingTightEm,
),
titleSmall: TextStyle(
fontSize: titleSmall,
fontWeight: semiBold,
height: lineHeightHeading,
letterSpacing: titleSmall * letterSpacingTightEm,
),
bodyLarge: TextStyle(
fontSize: bodyLarge,
fontWeight: regular,
height: lineHeightBase,
),
bodyMedium: TextStyle(
fontSize: bodyMedium,
fontWeight: regular,
height: lineHeightBase,
),
bodySmall: TextStyle(
fontSize: bodySmall,
fontWeight: regular,
height: lineHeightBase,
),
labelLarge: TextStyle(
fontSize: labelLarge,
fontWeight: semiBold,
height: lineHeightTight,
),
labelMedium: TextStyle(
fontSize: labelMedium,
fontWeight: medium,
height: lineHeightTight,
),
labelSmall: TextStyle(
fontSize: labelSmall,
fontWeight: medium,
height: lineHeightTight,
),
).apply(fontFamily: AppFonts.family);
}
}
-1
View File
@@ -1 +0,0 @@
// Empty directory placeholder
+7
View File
@@ -0,0 +1,7 @@
/// 지금 시각을 주는 함수 — 시간에 기대는 로직(토큰 만료 · 알림 겹침 · 코드 단계 10분)은 이것을 주입받는다.
///
/// 시험은 손으로 돌리는 시계를 넣어 고정 대기 없이 시간을 넘긴다 (테스트 규칙 "결정적").
typedef Clock = DateTime Function();
/// 기본 시계
DateTime systemClock() => DateTime.now();
@@ -0,0 +1,6 @@
/// 프로바이더의 실패를 저절로 다시 하지 않는다 — 늘 null(다시 하지 않음)을 돌려준다.
///
/// Riverpod 3 은 `retry` 를 주지 않으면(null) 기본으로 다시 한다(실패한 프로바이더를 10번까지 다시 만든다 —
/// 그때마다 요청을 다시 보낸다). 앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 이것을 걸어 끄고,
/// 다시 하기는 화면의 [다시 시도]가 한다.
Duration? noAutomaticRetry(int retryCount, Object error) => null;
@@ -0,0 +1,53 @@
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
/// 로그인 단계의 결과 (계약 5절 "로그인" — `status`)
enum LoginStatus {
/// 로그인했다 — 토큰이 실려 온다
signedIn,
/// 비밀번호는 맞고 2단계 인증 코드가 필요하다 — 토큰 없음, `email` · `name` 은 null
twoFactorRequired,
}
/// `POST /api/auth/app/login` 의 응답 — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"
/// (웹 login 과 같은 평평한 칸 `status` · `email` · `name` · `restriction` · `twoFactorDeadline` + `tokens`)
class AppLoginResult {
const AppLoginResult({required this.status, this.session, this.tokens});
final LoginStatus status;
/// `signedIn` 일 때 직원 · 세션 칸
final AuthSessionInfo? session;
/// `signedIn` 일 때만 — 코드 단계는 null
final AuthTokens? tokens;
/// 응답을 읽는다. `signedIn` 인데 세션 칸 · 토큰이 없으면 서버와 계약이 어긋난 것이라 [FormatException].
/// [serverTime] 은 응답의 `Date` 머리 — 토큰의 남은 시간을 서버 시계로 잰다.
factory AppLoginResult.fromJson(
Map<String, Object?> json, {
DateTime? serverTime,
}) {
final Object? status = json['status'];
if (status == LoginStatus.twoFactorRequired.name) {
return const AppLoginResult(status: LoginStatus.twoFactorRequired);
}
if (status != LoginStatus.signedIn.name) {
throw const FormatException('로그인 응답의 status 를 알 수 없다');
}
final AuthSessionInfo? session = AuthSessionInfo.tryParse(json);
final AuthTokens? tokens = AuthTokens.tryParse(
json['tokens'],
serverTime: serverTime,
);
if (session == null || tokens == null) {
throw const FormatException('signedIn 응답에 직원 · 토큰 칸이 없다');
}
return AppLoginResult(
status: LoginStatus.signedIn,
session: session,
tokens: tokens,
);
}
}
@@ -0,0 +1,69 @@
/// 제한된 세션 (계약 6절) — 로그인은 됐지만 그 단계를 마칠 때까지 다른 API 는 403 `AUTH_010` 이다
enum SessionRestriction {
/// 새 비밀번호를 정할 때까지
passwordChange,
/// 2단계 인증을 켤 때까지
twoFactorSetup,
/// 앱이 모르는 제한 — 웹처럼 갱신 · 다시 보내기 없이 사전 문구와 [다른 계정으로 로그인]만 보인다
unknown;
/// 응답 칸을 읽는다 — 없거나 빈 값이면 제한 없음(null), 아는 이름이면 그 이름, 그 밖은 [unknown]
static SessionRestriction? parse(Object? value) {
if (value == null || value == '') return null;
for (final SessionRestriction restriction in SessionRestriction.values) {
if (restriction != unknown && restriction.name == value) {
return restriction;
}
}
return unknown;
}
}
/// 로그인한 직원과 세션 칸 — 로그인(`signedIn`) · me · 갱신 응답이 함께 싣는다(계약 1 · 6절)
class AuthSessionInfo {
const AuthSessionInfo({
required this.email,
required this.name,
this.restriction,
this.twoFactorDeadline,
});
final String email;
final String name;
/// 제한 — null 이면 제한 없음
final SessionRestriction? restriction;
/// 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각(UTC) — 안내 줄은 그 1ms 앞 날짜까지
final DateTime? twoFactorDeadline;
/// 응답의 세션 칸을 읽는다 — `email` · `name` 이 없으면(코드 단계 · 토큰만 실린 갱신 응답) null
static AuthSessionInfo? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? email = json['email'];
final Object? name = json['name'];
if (email is! String || email.isEmpty || name is! String) return null;
final Object? deadline = json['twoFactorDeadline'];
return AuthSessionInfo(
email: email,
name: name,
restriction: SessionRestriction.parse(json['restriction']),
twoFactorDeadline: deadline is String
? DateTime.tryParse(deadline)?.toUtc()
: null,
);
}
@override
bool operator ==(Object other) =>
other is AuthSessionInfo &&
other.email == email &&
other.name == name &&
other.restriction == restriction &&
other.twoFactorDeadline == twoFactorDeadline;
@override
int get hashCode => Object.hash(email, name, restriction, twoFactorDeadline);
}
@@ -0,0 +1,107 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/features/auth/data/models/app_login_result.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:dio/dio.dart';
/// 로그인 화면이 칸 아래 · 위쪽 상자에 직접 보이는 실패 — 알림을 띄우지 않는다
/// (계약 5절 "클라이언트": `AUTH_004` 위쪽 상자, `AUTH_006` · `AUTH_007` 코드 칸 아래, `VAL_001` 칸 검사)
const Set<String> kLoginInlineCodes = <String>{
'AUTH_004',
'AUTH_006',
'AUTH_007',
'VAL_001',
};
/// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다
const String kMalformedResponseCode = 'SYS_001';
/// 앱 로그인 API — 계약 `docs/mail/api-contract.md` 1절 "앱 로그인"(서버는 아직 없다 — 시험은 가짜 서버로 한다).
///
/// 응답 언래핑과 에러 코드는 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 [kMalformedResponseCode].
/// 비밀번호 · 코드 · 토큰은 로그 · 예외에 싣지 않는다 — 비밀이 든 요청(`ApiOptions.secret`)의 실패 예외는 본문과 Bearer 를 비운 채 온다.
class AuthRepository {
const AuthRepository(this._dio, this._refresher);
final Dio _dio;
final TokenRefresher _refresher;
/// 로그인 — 2단계 인증을 켠 직원은 코드 단계에서 [otpCode] 와 비밀번호를 한 번 더 보낸다(계약 5절)
Future<AppLoginResult> login({
required String email,
required String password,
String? otpCode,
}) async {
final Response<Object?> response = await _dio.post<Object?>(
ApiPaths.appLogin,
data: <String, Object?>{
'email': email,
'password': password,
'otpCode': ?otpCode,
},
// 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다)
options: ApiOptions.secret(silent: kLoginInlineCodes),
);
final Object? body = response.data;
if (body is Map<String, Object?>) {
try {
return AppLoginResult.fromJson(
body,
serverTime: serverTimeOf(response),
);
} on FormatException {
// 아래에서 같은 실패로 던진다
}
}
throw _malformed(response);
}
/// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기)
Future<AuthSessionInfo> me({bool silent = false}) async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.me,
options: silent ? ApiOptions.silent() : null,
);
final AuthSessionInfo? info = AuthSessionInfo.tryParse(response.data);
if (info == null) throw _malformed(response);
return info;
}
/// 지금 세션을 인증 인터셉터 줄 밖에서 묻는다 — `AUTH_010` 을 다시 맞출 때만 쓴다
/// (인터셉터가 그 결과를 기다리는 중이라 같은 줄로 보내면 서로 기다린다). 알림은 띄우지 않는다.
Future<AuthSessionInfo> readSessionDirect() async {
final Map<String, Object?> body = await _refresher.readSession();
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
if (info == null) {
throw apiFailure(
RequestOptions(path: ApiPaths.me),
kMalformedResponseCode,
);
}
return info;
}
/// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나).
/// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다.
Future<RefreshOutcome> refresh() => _refresher.refresh();
/// 서버 세션을 끝낸다 — 리프레시 토큰을 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다.
/// 서버는 어느 쪽도 맞지 않아도 성공으로 답한다. 알림은 띄우지 않는다.
Future<void> logout({
required String? refreshToken,
required String? accessToken,
}) =>
postAppLogout(_dio, refreshToken: refreshToken, accessToken: accessToken);
DioException _malformed(Response<Object?> response) => withoutSecrets(
apiFailure(
response.requestOptions,
kMalformedResponseCode,
response: response,
),
);
}
@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:arasystem_app/core/utils/clock.dart';
/// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계)
const Duration kLoginSecretLifetime = Duration(minutes: 10);
/// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다.
///
/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다).
/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분.
/// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다.
class LoginSecret {
LoginSecret({
Clock clock = systemClock,
Duration lifetime = kLoginSecretLifetime,
}) : _clock = clock,
_lifetime = lifetime;
final Clock _clock;
final Duration _lifetime;
String? _email;
String? _password;
DateTime? _confirmedAt;
Timer? _timer;
/// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다
void hold(String email, String password) {
clear();
_email = email;
_password = password;
renew();
}
/// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다
void renew() {
if (_password == null) return;
_confirmedAt = _clock();
_timer?.cancel();
_timer = Timer(_lifetime, clear);
}
/// 쥐고 있는 주소 · 비밀번호 — 없거나 10분이 지났으면 null(지난 것은 지운다)
({String email, String password})? read() {
final String? email = _email;
final String? password = _password;
final DateTime? confirmedAt = _confirmedAt;
if (email == null || password == null || confirmedAt == null) return null;
if (_clock().difference(confirmedAt) >= _lifetime) {
clear();
return null;
}
return (email: email, password: password);
}
/// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다)
bool get isHeld => _password != null;
void clear() {
_timer?.cancel();
_timer = null;
_email = null;
_password = null;
_confirmedAt = null;
}
}
@@ -0,0 +1,381 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/session_events.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/storage/auth_tokens.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/auth/data/models/app_login_result.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 인증 저장소
final Provider<AuthRepository> authRepositoryProvider =
Provider<AuthRepository>(
(Ref ref) => AuthRepository(
ref.watch(dioProvider),
ref.watch(tokenRefresherProvider),
),
);
/// 세션 — 앱에 하나. 라우터 redirect 가 이것을 보고 화면을 고른다.
///
/// 되살리기가 장애로 실패해도 저절로 다시 하지 않는다(`noAutomaticRetry` — Riverpod 3 은 `retry` 를 주지 않으면 기본으로 다시 한다).
/// 시작 화면이 [SessionNotifier.retry] 를 부른다. 앱 전체도 ProviderScope 의 `retry` 로 끈다(main.dart).
final AsyncNotifierProvider<SessionNotifier, SessionState> sessionProvider =
AsyncNotifierProvider<SessionNotifier, SessionState>(
SessionNotifier.new,
retry: noAutomaticRetry,
);
/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기.
///
/// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다.
/// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다.
/// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다.
/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다.
/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의
/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게).
class SessionNotifier extends AsyncNotifier<SessionState> {
LoginSecret? _secretHolder;
Future<RestrictionCheck>? _rechecking;
int _epoch = 0;
LoginSecret get _secret =>
_secretHolder ??= LoginSecret(clock: ref.read(clockProvider));
AuthRepository get _repository => ref.read(authRepositoryProvider);
TokenStore get _store => ref.read(tokenStoreProvider);
TokenRefresher get _refresher => ref.read(tokenRefresherProvider);
PushRegistrar get _push => ref.read(pushRegistrarProvider);
@override
Future<SessionState> build() async {
final StreamSubscription<SessionEvent> subscription = ref
.watch(sessionEventsProvider)
.stream
.listen(_onEvent);
ref.onDispose(() {
unawaited(subscription.cancel());
_secretHolder?.clear();
});
final TokenStore store = ref.watch(tokenStoreProvider);
if (!store.hasSession) return const SignedOut();
return _restore();
}
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
({String email, String password})? get heldLogin => _secret.read();
/// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다
void retry() {
_refresher.forgetFailure();
ref.invalidateSelf();
}
/// 비밀번호 단계
Future<void> signIn({required String email, required String password}) async {
final int epoch = _epoch;
final AppLoginResult result = await _repository.login(
email: email,
password: password,
);
await _apply(result, epoch: epoch, email: email, password: password);
}
/// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절).
///
/// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다.
Future<void> submitCode(String otpCode) async {
final ({String email, String password})? held = _secret.read();
if (held == null) {
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.codeTimedOut),
);
return;
}
final int epoch = _epoch;
final AppLoginResult result;
try {
result = await _repository.login(
email: held.email,
password: held.password,
otpCode: otpCode,
);
} on DioException catch (error) {
// 그사이 [다른 계정으로 로그인] · 로그아웃했으면 지금 화면을 건드리지 않는다
if (epoch == _epoch &&
ref.mounted &&
apiErrorCodeOf(error) == 'AUTH_004') {
_secret.clear();
state = const AsyncData<SessionState>(SignedOut());
}
rethrow;
}
await _apply(
result,
epoch: epoch,
email: held.email,
password: held.password,
);
}
/// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고,
/// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다.
Future<void> switchAccount() async {
if (_store.hasSession || state.value is SignedIn) {
await signOut(reason: SignOutReason.switchAccount);
return;
}
_epoch++;
_secret.clear();
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.switchAccount),
);
}
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다.
/// 서버에 닿지 못해도 이 기기는 로그아웃 상태다.
Future<void> signOut({SignOutReason reason = SignOutReason.none}) async {
_epoch++;
_secret.clear();
// 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다)
final Future<SignOutTokens> clearing = _store.signOut();
state = AsyncData<SessionState>(SignedOut(reason: reason));
final SignOutTokens tokens = await clearing;
unawaited(_unregisterPushQuietly());
if (tokens.refreshToken == null && tokens.accessToken == null) return;
try {
await _repository.logout(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
);
} on DioException {
// 서버 세션은 쓰지 않으면 미사용 14일에 끝난다
}
}
/// 제한(`AUTH_010`)을 다시 맞춘다 — 웹 `recheckRestriction` 과 같은 결. 겹친 요청은 하나로 묶는다.
///
/// 세션을 인증 인터셉터 줄 밖에서 묻는다(인터셉터가 이 결과를 기다리는 중이라).
/// - 제한이 있으면 그 단계로 옮긴다 → [RestrictionCheck.restricted]
/// - 제한이 없다는데 `AUTH_010` 이면 옛 토큰이다 — 갱신해 새 토큰을 받는다 → [RestrictionCheck.renewed]
/// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale]
/// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown]
Future<RestrictionCheck> recheck() {
return _rechecking ??= _recheck().whenComplete(() => _rechecking = null);
}
Future<RestrictionCheck> _recheck() async {
final int epoch = _epoch;
if (state.value is! SignedIn) return RestrictionCheck.stale;
bool isCurrent() =>
epoch == _epoch && ref.mounted && state.value is SignedIn;
AuthSessionInfo? info;
try {
info = await _repository.readSessionDirect();
} on DioException catch (error) {
if (!isCurrent()) return RestrictionCheck.stale;
// 액세스 토큰이 끝났으면 갱신해 본다(아래) — 그 밖의 장애는 확인하지 못한 것이다
if (apiErrorCodeOf(error) != 'AUTH_001') return RestrictionCheck.unknown;
}
if (!isCurrent()) return RestrictionCheck.stale;
if (info != null && info.restriction != null) {
_setSignedIn(info);
return RestrictionCheck.restricted;
}
final RefreshOutcome outcome = await _repository.refresh();
if (!isCurrent()) return RestrictionCheck.stale;
switch (outcome) {
case RefreshSucceeded(:final Map<String, Object?>? body):
final AuthSessionInfo? refreshed =
AuthSessionInfo.tryParse(body) ?? info;
if (refreshed == null) return RestrictionCheck.renewed;
_setSignedIn(refreshed);
return refreshed.restriction == null
? RestrictionCheck.renewed
: RestrictionCheck.restricted;
case RefreshRejected():
case RefreshDiscarded():
// 세션 끝 흐름이 로그인 화면으로 옮긴다
return RestrictionCheck.stale;
case RefreshFailed():
return RestrictionCheck.unknown;
}
}
Future<SessionState> _restore() async {
final int epoch = _epoch;
final AuthSessionInfo info;
try {
info = await _repository.me(silent: true);
} on DioException catch (error) {
// 되살리는 사이 [다른 계정으로 로그인] · 로그아웃했다 — 그 상태를 그대로 둔다
if (epoch != _epoch) return _currentSignedOut();
final String? code = apiErrorCodeOf(error);
if (code == 'AUTH_001' || code == 'AUTH_002') {
await _clearQuietly();
return const SignedOut();
}
// 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다
rethrow;
}
if (epoch != _epoch) return _currentSignedOut();
_afterSignedIn(info, wasReady: false);
return SignedIn(info);
}
/// 되살리는 사이 바뀐 로그아웃 상태 — build 의 결과가 그 상태를 덮어쓰지 않게 같은 값을 돌려준다
SessionState _currentSignedOut() {
final SessionState? current = state.value;
return current is SignedOut ? current : const SignedOut();
}
Future<void> _apply(
AppLoginResult result, {
required int epoch,
required String email,
required String password,
}) async {
if (!ref.mounted || epoch != _epoch) {
// 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다
final AuthTokens? tokens = result.tokens;
if (tokens != null) unawaited(_logoutQuietly(tokens));
return;
}
switch (result.status) {
case LoginStatus.twoFactorRequired:
_secret.hold(email, password);
state = AsyncData<SessionState>(CodeRequired(email));
case LoginStatus.signedIn:
final AuthTokens tokens = result.tokens!;
// 저장은 메모리를 곧바로 바꾸고 세대를 올린다 — 이 저장의 세대는 기다리기 전에 읽는다
final Future<void> saving = _store.save(tokens);
final int savedGeneration = _store.generation;
try {
await saving;
} on PlatformException {
// 받은 토큰을 둘 곳이 없다 — 이 기기의 토큰을 지우고 그 세션도 끝낸 뒤 실패로 알린다
if (_store.generation == savedGeneration) await _clearQuietly();
unawaited(_logoutQuietly(tokens));
throw apiFailure(
RequestOptions(path: ApiPaths.appLogin),
kMalformedResponseCode,
);
}
if (!ref.mounted || epoch != _epoch) {
// 저장하는 사이 로그아웃했다 — 그 뒤에 다른 일이 없었으면 방금 둔 토큰을 지우고, 그 세션은 끝낸다
if (_store.generation == savedGeneration) await _clearQuietly();
unawaited(_logoutQuietly(tokens));
return;
}
final AuthSessionInfo info = result.session!;
if (info.restriction != null) {
// 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다
_secret.hold(email, password);
}
_setSignedIn(info);
}
}
/// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다
void _setSignedIn(AuthSessionInfo info) {
final SessionState? previous = state.value;
final bool wasReady =
previous is SignedIn && previous.info.restriction == null;
state = AsyncData<SessionState>(SignedIn(info));
_afterSignedIn(info, wasReady: wasReady);
}
void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) {
if (info.restriction != null) return;
// 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일")
_secret.clear();
if (!wasReady) unawaited(_registerPush());
}
Future<void> _registerPush() async {
try {
await _push.register();
} on Object {
// 알림 등록이 안 돼도 로그인은 그대로다 — 다음 로그인 · 되살리기 때 다시 한다
}
}
Future<void> _unregisterPushQuietly() async {
try {
await _push.unregister().timeout(kPushUnregisterTimeout);
} on Object {
// 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다
}
}
void _onEvent(SessionEvent event) {
final SessionState? current = state.value;
switch (event) {
case SessionRecheckNeeded():
if (current is! SignedIn) {
event.reply(RestrictionCheck.stale);
return;
}
unawaited(
recheck().then<void>(
event.reply,
onError: (Object _, StackTrace _) =>
event.reply(RestrictionCheck.unknown),
),
);
case SessionEnded():
if (current is! SignedIn) return;
_epoch++;
_secret.clear();
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.expired),
);
case SessionRefreshed(:final Map<String, Object?> body):
// 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다)
if (current is! SignedIn) return;
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
if (info != null &&
info.email == current.info.email &&
info != current.info) {
_setSignedIn(info);
}
}
}
Future<void> _logoutQuietly(AuthTokens tokens) async {
try {
await _repository.logout(
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
);
} on DioException {
// 닿지 못해도 쓰지 않는 세션은 미사용 14일에 끝난다
}
}
Future<void> _clearQuietly() async {
try {
await _store.clear();
} on PlatformException {
// 메모리의 토큰은 지웠다 — 남은 값은 다음 실행의 갱신이 거절되면 지운다
}
}
}
@@ -0,0 +1,65 @@
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
/// 세션 상태 — 라우터의 redirect 가 이것으로 화면을 고른다(core/router/session_redirect.dart).
///
/// "되살리는 중"은 상태가 아니라 세션 노티파이어의 `AsyncLoading` 이고, 되살리기가 장애로 실패하면 `AsyncError` 다.
sealed class SessionState {
const SessionState();
}
/// 로그아웃 상태 — 로그인 화면
final class SignedOut extends SessionState {
const SignedOut({this.reason = SignOutReason.none});
/// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다)
final SignOutReason reason;
@override
bool operator ==(Object other) =>
other is SignedOut && other.reason == reason;
@override
int get hashCode => reason.hashCode;
}
/// 로그아웃한 까닭
enum SignOutReason {
/// 처음 · 스스로 로그아웃
none,
/// 세션이 끝났다(`AUTH_002` — 갱신 거절 · 다른 곳에서 끊음)
expired,
/// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.")
codeTimedOut,
/// [다른 계정으로 로그인]
switchAccount,
}
/// 비밀번호는 맞고 2단계 인증 코드를 기다린다 — 비밀번호는 상태에 두지 않는다(노티파이어 메모리에만 10분)
final class CodeRequired extends SessionState {
const CodeRequired(this.email);
final String email;
@override
bool operator ==(Object other) =>
other is CodeRequired && other.email == email;
@override
int get hashCode => email.hashCode;
}
/// 로그인했다 — 제한이 있으면 그 단계 화면, 없으면 메일
final class SignedIn extends SessionState {
const SignedIn(this.info);
final AuthSessionInfo info;
@override
bool operator ==(Object other) => other is SignedIn && other.info == info;
@override
int get hashCode => info.hashCode;
}
@@ -0,0 +1,66 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
/// 메일 모듈의 경로 — 모두 `/mail` 아래다
abstract final class MailRoutes {
/// 첫 화면 — 받은편지함 목록 (탭 안)
static final String home = findAppModule(AppModuleId.mail).rootPath;
/// 다른 메일함의 목록 (탭 안 — 탭을 바꿔도 이 기록이 남는다)
static String mailbox(String mailboxId) =>
'$home/mailboxes/${Uri.encodeComponent(mailboxId)}';
/// 메일 읽기 (전체 화면 — 탭 막대 없이)
static String message(String messageId) =>
'$home/messages/${Uri.encodeComponent(messageId)}';
/// 메일 쓰기 (전체 화면 — 탭 막대 없이)
static String get compose => '$home/compose';
}
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 화면은 모두 자리 화면이다. 캔버스(2026-11-05 확정 — 목록 · 읽기 · 쓰기)대로 화면을 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 둔다).
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
id: AppModuleId.mail,
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
locationOfItem: MailRoutes.message,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: MailRoutes.home,
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
title: MailTexts.home,
links: <PlaceholderLink>[
PlaceholderLink(
label: MailTexts.compose,
location: MailRoutes.compose,
),
],
),
routes: <RouteBase>[
GoRoute(
path: 'mailboxes/:mailboxId',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.mailbox),
),
GoRoute(
path: 'messages/:messageId',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.message),
),
GoRoute(
path: 'compose',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.compose),
),
],
),
],
);
@@ -0,0 +1,16 @@
/// 메일 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(캔버스 확정본 기준).
abstract final class MailTexts {
/// 메일 첫 화면 — 받은편지함 목록
static const String home = '메일';
/// 다른 메일함의 목록 (탭 안)
static const String mailbox = '메일함';
/// 메일 읽기 (전체 화면)
static const String message = '메일 읽기';
/// 메일 쓰기 (전체 화면)
static const String compose = '메일 쓰기';
}
@@ -0,0 +1,25 @@
/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤).
abstract final class SettingsTexts {
/// 설정 첫 화면
static const String home = '설정';
/// 내 정보 — 이름 · 주소
static const String account = '내 정보';
/// 알림 켜기 · 끄기
static const String notifications = '알림';
/// 앱 정보 — 판 · 오픈소스 라이선스
static const String about = '앱 정보';
/// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry)
static const String licenses = '오픈소스 라이선스';
/// 문의 연락처 (`GET /api/support-contact`)
static const String support = '문의 연락처';
/// 로그아웃 (화면이 아니라 하는 일)
static const String signOut = '로그아웃';
}
@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 설정 모듈의 경로 — 모두 `/settings` 아래다
abstract final class SettingsRoutes {
/// 첫 화면 — 설정 목록 (탭 안)
static final String home = findAppModule(AppModuleId.settings).rootPath;
/// 내 정보 — 이름 · 주소
static String get account => '$home/account';
/// 알림 켜기 · 끄기
static String get notifications => '$home/notifications';
/// 앱 정보 — 판 · 오픈소스 라이선스
static String get about => '$home/about';
/// 오픈소스 라이선스 (전체 화면)
static String get licenses => '$about/licenses';
/// 문의 연락처
static String get support => '$home/support';
/// 다른 모듈이 내놓은 설정 절 (`/settings/<path>`)
static String section(String path) => '$home/$path';
}
/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃.
/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다.
final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
id: AppModuleId.settings,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: SettingsRoutes.home,
builder: (BuildContext _, GoRouterState _) =>
_SettingsHomePlaceholder(sections: context.settingsSections),
routes: <RouteBase>[
GoRoute(
path: 'account',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.account),
),
GoRoute(
path: 'notifications',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.notifications),
),
GoRoute(
path: 'about',
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
title: SettingsTexts.about,
links: <PlaceholderLink>[
PlaceholderLink(
label: SettingsTexts.licenses,
location: SettingsRoutes.licenses,
),
],
),
routes: <RouteBase>[
GoRoute(
path: 'licenses',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const _LicensesPage(),
),
],
),
GoRoute(
path: 'support',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.support),
),
for (final ModuleSettingsSection section in context.settingsSections)
GoRoute(path: section.path, builder: section.builder),
],
),
],
);
/// 설정 첫 화면 자리 — 절 이름과 [로그아웃]
class _SettingsHomePlaceholder extends ConsumerWidget {
const _SettingsHomePlaceholder({required this.sections});
final List<ModuleSettingsSection> sections;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PlaceholderPage(
title: SettingsTexts.home,
links: <PlaceholderLink>[
PlaceholderLink(
label: SettingsTexts.account,
location: SettingsRoutes.account,
),
PlaceholderLink(
label: SettingsTexts.notifications,
location: SettingsRoutes.notifications,
),
for (final ModuleSettingsSection section in sections)
PlaceholderLink(
label: section.title,
location: SettingsRoutes.section(section.path),
),
PlaceholderLink(
label: SettingsTexts.about,
location: SettingsRoutes.about,
),
PlaceholderLink(
label: SettingsTexts.support,
location: SettingsRoutes.support,
),
PlaceholderLink(
label: SettingsTexts.signOut,
onTap: () => unawaited(ref.read(sessionProvider.notifier).signOut()),
),
],
);
}
}
/// 오픈소스 라이선스 — Flutter 라이선스 화면(LicenseRegistry — Pretendard OFL 포함)
class _LicensesPage extends ConsumerWidget {
const _LicensesPage();
@override
Widget build(BuildContext context, WidgetRef ref) {
return LicensePage(
applicationName: kAppName,
applicationVersion: ref.watch(appVersionProvider),
);
}
}
@@ -1,27 +1,24 @@
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다.
/// (freezed/json_serializable 도입 시 이 클래스를 생성 모델로 교체)
/// 사용자 모델 — 백엔드 UserResponseDto 와 1:1 대응한다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
///
/// 모델은 손으로 쓴다 — 생성기(freezed · json_serializable)를 넣을 때의 주의는 `flutter/README.md` "build_runner".
class UserModel {
const UserModel({
required this.id,
required this.email,
required this.name,
});
const UserModel({required this.id, required this.email, required this.name});
final int id;
final String email;
final String name;
factory UserModel.fromJson(Map<String, dynamic> json) {
factory UserModel.fromJson(Map<String, Object?> json) {
return UserModel(
id: json['id'] as int,
email: json['email'] as String,
name: json['name'] as String,
id: json['id']! as int,
email: json['email']! as String,
name: json['name']! as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
};
Map<String, Object?> toJson() => <String, Object?>{
'id': id,
'email': email,
'name': name,
};
}
@@ -1,8 +1,7 @@
import 'package:arasystem_app/features/user/data/models/user_model.dart';
import 'package:dio/dio.dart';
import '../models/user_model.dart';
/// 사용자 API 호출 구현체 — 새 도메인은 이 패턴을 복제한다.
/// 사용자 API 호출 구현체 — 목록 페이지네이션(`{items, pageInfo}`)의 예제다. (템플릿 예제 — 앱 API 에 `/users` 는 없다)
///
/// 응답 언래핑({ success, data } → data)과 에러 코드 매핑은 dio 인터셉터가 담당하므로
/// 리포지토리는 파싱만 책임진다. (여기서 try/catch 로 메시지를 만들지 않는다)
@@ -12,7 +11,8 @@ class UserRepository {
final Dio _dio;
Future<UserModel> getUser(int id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
final Response<Map<String, Object?>> response = await _dio
.get<Map<String, Object?>>('/users/$id');
return UserModel.fromJson(response.data!);
}
@@ -21,19 +21,20 @@ class UserRepository {
int limit = 20,
String? cursor,
}) async {
final response = await _dio.get<Map<String, dynamic>>(
'/users',
queryParameters: {
'limit': limit,
'cursor': ?cursor,
},
);
final Response<Map<String, Object?>> response = await _dio
.get<Map<String, Object?>>(
'/users',
queryParameters: <String, Object?>{'limit': limit, 'cursor': ?cursor},
);
final data = response.data!;
final items = (data['items'] as List<dynamic>)
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
final Map<String, Object?> data = response.data!;
final List<UserModel> items = (data['items']! as List<Object?>)
.map(
(Object? json) => UserModel.fromJson(json! as Map<String, Object?>),
)
.toList();
final pageInfo = data['pageInfo'] as Map<String, dynamic>;
final Map<String, Object?> pageInfo =
data['pageInfo']! as Map<String, Object?>;
return (
items: items,
@@ -1,15 +1,18 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/user/data/models/user_model.dart';
import 'package:arasystem_app/features/user/data/repositories/user_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/api/dio_provider.dart';
import '../../data/models/user_model.dart';
import '../../data/repositories/user_repository.dart';
import 'package:flutter_riverpod/misc.dart';
// 리포지토리 Provider — dioProvider 를 주입받아 생성
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(dioProvider));
});
/// 리포지토리 Provider — dioProvider 를 주입받아 생성 (템플릿 예제)
final Provider<UserRepository> userRepositoryProvider =
Provider<UserRepository>(
(Ref ref) => UserRepository(ref.watch(dioProvider)),
);
// 특정 사용자 조회 Provider (family)
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
return ref.watch(userRepositoryProvider).getUser(id);
});
/// 특정 사용자 조회 Provider (family)
/// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
final FutureProviderFamily<UserModel, int> userProvider =
FutureProvider.family<UserModel, int>(
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
);
+40 -54
View File
@@ -1,67 +1,53 @@
import 'package:arasystem_app/app.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/theme/app_fonts.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// `.env` 파일 이름 (pubspec assets)
const String _envFileName = '.env';
Future<void> main() async {
// dotenv 로드 — .env 파일이 없으면 기본값으로 동작
WidgetsFlutterBinding.ensureInitialized();
// .env 가 없으면 기본 주소로 동작한다 (ApiConfig.fromEnv)
try {
await dotenv.load(fileName: '.env');
} catch (_) {
// .env 파일이 없는 경우 무시 (기본값 사용)
await dotenv.load(fileName: _envFileName);
} on FileNotFoundError {
// 기본값을 쓴다
} on EmptyEnvFileError {
// 기본값을 쓴다
}
AppFonts.registerFontLicenses();
// 토큰 보관 준비 — 첫 실행이면 보안 저장소에 남은 값을 지운다(앱을 띄우기 전에 끝낸다)
final TokenStore tokenStore = TokenStore(
secure: const FlutterSecureStore(),
firstRun: PrefsFirstRunMarker(),
);
await tokenStore.prepare();
final ({String? appVersion, String userAgent}) device =
await loadAppDeviceInfo();
runApp(
// Riverpod Provider Scope 적용
const ProviderScope(
child: MyApp(),
ProviderScope(
// 프로바이더의 실패를 저절로 다시 하지 않는다(core/utils/provider_retry.dart)
retry: noAutomaticRetry,
overrides: [
tokenStoreProvider.overrideWithValue(tokenStore),
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
],
child: const App(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TMA App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomeView(),
);
}
}
class HomeView extends StatelessWidget {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TMA Global Architecture'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Riverpod & Dio Interceptor Setup Complete.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 테스트 용 임시 스낵바 알림 호출 (User-Friendly Error UI)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('에러 발생 시 보여질 Toast 예시입니다.')),
);
},
child: const Text('Show Global Alert Test'),
),
],
),
),
);
}
}
-1
View File
@@ -1 +0,0 @@
// Empty directory placeholder
@@ -0,0 +1,65 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 자리 화면의 줄 하나 — 누르면 [location] 으로 가거나 [onTap] 을 부른다
@immutable
class PlaceholderLink {
const PlaceholderLink({required this.label, this.location, this.onTap});
final String label;
/// 갈 경로 (`context.push` — 뒤로 가기로 돌아온다)
final String? location;
final VoidCallback? onTap;
}
/// 자리 화면 — 화면 이름과(있으면) 갈 곳의 이름만 보인다.
///
/// 화면은 캔버스로 확인받은 뒤 만든다. 확정되면 그 모듈의 라우트 묶음(`features/<모듈>/<모듈>_routes.dart`)의
/// builder 를 그 화면으로 바꾸고, 쓰는 곳이 없어지면 이 위젯을 지운다.
class PlaceholderPage extends StatelessWidget {
const PlaceholderPage({
required this.title,
this.links = const <PlaceholderLink>[],
super.key,
});
final String title;
final List<PlaceholderLink> links;
@override
Widget build(BuildContext context) {
final TextTheme text = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(AppSpacing.s24),
children: <Widget>[
Text(title, textAlign: TextAlign.center, style: text.titleLarge),
if (links.isNotEmpty) const SizedBox(height: AppSpacing.s16),
for (final PlaceholderLink link in links) _LinkTile(link: link),
],
),
),
);
}
}
class _LinkTile extends StatelessWidget {
const _LinkTile({required this.link});
final PlaceholderLink link;
@override
Widget build(BuildContext context) {
final String? location = link.location;
final VoidCallback? onTap = link.onTap;
return ListTile(
title: Text(link.label),
onTap:
onTap ??
(location == null ? null : () => context.push<void>(location)),
);
}
}