feat(flutter): 로그인 네 단계 화면을 만든다 (앱 1단계 — App1Login)

주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를
세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인].

- 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3,
  canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸
- 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서
  상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만
- 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다),
  비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다
- 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판)
- 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건

리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판,
계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 15:57:39 +09:00
parent dd02b9621a
commit c14580e3a4
54 changed files with 6597 additions and 73 deletions
@@ -0,0 +1,37 @@
import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart';
import 'package:url_launcher/url_launcher.dart';
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
typedef UrlLaunch = Future<bool> Function(Uri url, LaunchMode mode);
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
launchUrl(url, mode: mode);
/// [인증 앱에 추가] — otpauth 주소(키가 들어 있다)를 그 주소를 받는 인증 앱에 넘긴다(2단계 켜기 · 같은 휴대폰이라 QR 대신).
///
/// - 브라우저가 아닌 앱만 연다(`externalNonBrowserApplication` — Android 11+ 의 FLAG_ACTIVITY_REQUIRE_NON_BROWSER).
/// 받을 앱이 없으면 false — 화면이 "인증 앱을 찾지 못했습니다"와 키를 직접 넣는 길을 보인다.
/// - **`canLaunchUrl` 을 쓰지 않는다** — url_launcher_android 의 debug 판이 묻는 주소를 로그에 남긴다(키가 들어 있다).
/// 그래서 매니페스트 `<queries>` 도 필요 없다: `launchUrl` 은 startActivity 를 바로 부르고 받을 앱이 없으면
/// ActivityNotFoundException 을 false 로 바꾼다(앱 보기 제한은 resolveActivity 류에만 걸린다).
/// - 못 열면 플러그인이 던지는 `PlatformException` 의 문장에 주소째(키째) 들어 있다 — 잡아서 버린다(로그 · 상태에 남기지 않는다).
class AuthenticatorLauncher {
const AuthenticatorLauncher({UrlLaunch launch = _launchExternal})
: _launch = launch;
final UrlLaunch _launch;
/// 인증 앱을 열었으면 true. otpauth 주소가 아니거나 받을 앱이 없거나 열지 못했으면 false
Future<bool> open(String otpauthUrl) async {
if (!otpauthUrl.startsWith(kOtpauthUrlPrefix)) return false;
final Uri? url = Uri.tryParse(otpauthUrl);
if (url == null) return false;
try {
return await _launch(url, LaunchMode.externalNonBrowserApplication);
} on Object {
// PlatformException(받을 앱 없음 · 화면 없음) · ArgumentError(지원하지 않는 방식) 모두 —
// 문장에 주소(키)가 들어 있어 남기지 않고 버린다. 화면은 키를 직접 넣는 길을 보인다
return false;
}
}
}
@@ -0,0 +1,48 @@
/// 2단계 인증 등록 — `POST /api/two-factor/enrollments` 의 응답 (계약 5절 "2단계 인증").
///
/// **비밀이다.** 키([secret])와 otpauth 주소([otpauthUrl])는 2단계 켜기 화면의 메모리에만 둔다 —
/// Riverpod 상태 · 로그 · 예외 문장에 두지 않고, 켜거나 화면을 떠나면 버린다. [toString] 도 값을 가린다.
class TwoFactorEnrollment {
const TwoFactorEnrollment({
required this.enrollmentId,
required this.secret,
required this.otpauthUrl,
this.expiresAt,
});
/// 켜기 요청에 함께 보낸다
final String enrollmentId;
/// base32 키(32자) — [복사] · 직접 입력용
final String secret;
/// `otpauth://totp/…` — [인증 앱에 추가]가 이 주소로 인증 앱을 연다
final String otpauthUrl;
/// 이때까지 켜야 한다(10분) — 지나면 서버가 `AUTH_008`
final DateTime? expiresAt;
/// 응답을 읽는다 — 칸이 비었거나 모양이 틀리면 null
static TwoFactorEnrollment? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['enrollmentId'];
final Object? secret = json['secret'];
final Object? url = json['otpauthUrl'];
if (id is! String || id.isEmpty) return null;
if (secret is! String || secret.isEmpty) return null;
if (url is! String || !url.startsWith(kOtpauthUrlPrefix)) return null;
final Object? expires = json['expiresAt'];
return TwoFactorEnrollment(
enrollmentId: id,
secret: secret,
otpauthUrl: url,
expiresAt: expires is String ? DateTime.tryParse(expires)?.toUtc() : null,
);
}
@override
String toString() => 'TwoFactorEnrollment(***)';
}
/// otpauth 주소의 앞부분 — 다른 꼴의 주소로는 다른 앱을 열지 않는다
const String kOtpauthUrlPrefix = 'otpauth://';
@@ -0,0 +1,17 @@
/// 2단계 인증 상태 — `GET /api/two-factor` · 켜기 응답 (계약 5절 "2단계 인증" · 6절 `isRequired` · `deadline`).
///
/// 앱은 로그인 틀에서만 쓴다: 새 비밀번호 단계가 코드 칸을 보일지(켠 직원은 비밀번호를 바꿀 때 코드가 든다),
/// 2단계 켜기 단계가 그사이 다른 곳에서 켰는지(`AUTH_008`) 본다. 계정 설정 화면은 웹에 있다.
class TwoFactorStatus {
const TwoFactorStatus({required this.isEnabled});
final bool isEnabled;
/// 응답을 읽는다 — `isEnabled` 가 없으면 null
static TwoFactorStatus? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? enabled = json['isEnabled'];
if (enabled is! bool) return null;
return TwoFactorStatus(isEnabled: enabled);
}
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
@@ -19,6 +20,23 @@ const Set<String> kLoginInlineCodes = <String>{
'VAL_001',
};
/// 코드 단계(코드를 실은 로그인)가 직접 보이는 실패 — 첫 단계 묶음에 시도 과다(`REQ_001` — 위쪽 상자)를 더한다.
/// 웹 코드 단계와 같다(웹 디자인 README 12단계 "로그인 — 코드 단계"). 첫 단계의 `REQ_001` 은 웹처럼 전역 알림이다
const Set<String> kLoginCodeInlineCodes = <String>{
...kLoginInlineCodes,
'REQ_001',
};
/// 새 비밀번호 단계가 직접 보이는 실패 — 알림을 띄우지 않는다
/// (`AUTH_005` 임시 비밀번호 칸 · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `REQ_001` 위쪽 상자 — 계약 1 · 5 · 6절, 웹과 같다)
const Set<String> kPasswordChangeInlineCodes = <String>{
'AUTH_005',
'AUTH_006',
'AUTH_007',
'VAL_001',
'REQ_001',
};
/// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다
const String kMalformedResponseCode = 'SYS_001';
@@ -47,7 +65,9 @@ class AuthRepository {
'otpCode': ?otpCode,
},
// 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다)
options: ApiOptions.secret(silent: kLoginInlineCodes),
options: ApiOptions.secret(
silent: otpCode == null ? kLoginInlineCodes : kLoginCodeInlineCodes,
),
);
final Object? body = response.data;
if (body is Map<String, Object?>) {
@@ -100,6 +120,28 @@ class AuthRepository {
return info;
}
/// 비밀번호를 바꾼다 — 새 비밀번호 단계(계약 1절 `PUT /api/auth/password` · 5절 `otpCode` · 6절 제한 풀기).
///
/// [otpCode] 는 2단계 인증을 켠 직원만. 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비운 채 온다.
/// 서버가 엔진을 두 번 부르고 세션을 끊어 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초).
Future<void> changePassword({
required String currentPassword,
required String newPassword,
String? otpCode,
}) async {
await _dio.put<Object?>(
ApiPaths.password,
data: <String, Object?>{
'currentPassword': currentPassword,
'newPassword': newPassword,
'otpCode': ?otpCode,
},
options: ApiOptions.secret(
silent: kPasswordChangeInlineCodes,
).copyWith(receiveTimeout: kSecuritySettingTimeout),
);
}
/// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나).
/// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다.
Future<RefreshOutcome> refresh() => _refresher.refresh();
@@ -0,0 +1,26 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/utils/display_text.dart';
import 'package:dio/dio.dart';
/// 문의 연락처 — `GET /api/support-contact` (로그인 없음 · 계약 6절 "문의 연락처").
///
/// 운영 도구로 정하는 회사 설정값 한 줄이다(200자). 정하지 않았으면 null. 알림은 띄우지 않는다 —
/// 받지 못해도 화면은 지금 글 그대로다(웹과 같다).
class SupportContactRepository {
const SupportContactRepository(this._dio);
final Dio _dio;
/// 연락처 한 줄 — 정하지 않았거나 모양이 틀리면 null. 실패는 `DioException`
Future<String?> fetch() async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.supportContact,
options: ApiOptions.silent(),
);
final Object? data = response.data;
return data is Map<String, Object?>
? supportContactTextOf(data['text'])
: null;
}
}
@@ -0,0 +1,84 @@
import 'package:arasystem_app/core/api/api_config.dart';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart';
import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart';
import 'package:dio/dio.dart';
/// 2단계 켜기 단계가 직접 보이는 실패 — 알림을 띄우지 않는다
/// (`AUTH_005` 지금 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `AUTH_008` 위쪽 상자와 새 키 · `REQ_001` 위쪽 상자 — 계약 5절 "2단계 인증", 웹과 같다).
/// `VAL_001` 은 넣지 않는다 — 코드는 보내기 전에 6자리를 보므로 서버의 `VAL_001` 은 지금 비밀번호 쪽이고, 웹처럼 전역 알림이다
const Set<String> kTwoFactorSetupInlineCodes = <String>{
'AUTH_005',
'AUTH_006',
'AUTH_007',
'AUTH_008',
'REQ_001',
};
/// 2단계 인증 API — 로그인 틀(새 비밀번호 · 2단계 켜기)이 쓰는 것만. 계약 5절 "2단계 인증" · 6절 "제한된 세션"(제한된 세션도 부를 수 있다).
///
/// 등록의 키 · otpauth 주소는 받은 값을 돌려주기만 한다 — 저장하지 않는다(화면 메모리에만, `TwoFactorEnrollment`).
/// 실패는 늘 `DioException`(`error` 에 `ApiException`). 응답 모양이 틀리면 [kMalformedResponseCode] — 응답은 싣지 않는다.
class TwoFactorRepository {
const TwoFactorRepository(this._dio);
final Dio _dio;
/// 지금 상태 — 알림은 띄우지 않는다(화면이 못 받아도 다른 길로 안다: 서버의 `AUTH_007` 등)
Future<TwoFactorStatus> status() async {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.twoFactor,
options: ApiOptions.silent(),
);
final TwoFactorStatus? status = TwoFactorStatus.tryParse(response.data);
if (status == null) throw _malformed(response);
return status;
}
/// 등록을 시작한다 — 새 키(10분 안에 켠다). 직원마다 기다리는 등록은 하나라 앞의 것은 서버가 버린다.
///
/// 응답에 키가 들어 있어 비밀 요청으로 보낸다(실패 예외의 응답 본문을 비운다). 알림은 띄우지 않는다 —
/// 화면이 키 자리에 사전 문구와 [다시 시도]를 보인다.
Future<TwoFactorEnrollment> startEnrollment() async {
final Response<Object?> response = await _dio.post<Object?>(
ApiPaths.twoFactorEnrollments,
options: ApiOptions.secret(silent: true),
);
final TwoFactorEnrollment? enrollment = TwoFactorEnrollment.tryParse(
response.data,
);
// 받은 응답 객체에 키를 남기지 않는다
response.data = null;
if (enrollment == null) throw _malformed(response);
return enrollment;
}
/// 켠다 — 등록의 키로 만든 코드와 지금 비밀번호를 함께 보낸다. 비밀이 본문에 든다.
/// 서버가 엔진을 여러 번 부르므로 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초).
/// 성공 응답의 상태는 쓰지 않는다 — 다음 화면은 새 토큰의 세션 칸이 정한다.
Future<void> activate({
required String enrollmentId,
required String otpCode,
required String currentPassword,
}) async {
await _dio.post<Object?>(
ApiPaths.twoFactorActivation,
data: <String, Object?>{
'enrollmentId': enrollmentId,
'otpCode': otpCode,
'currentPassword': currentPassword,
},
options: ApiOptions.secret(
silent: kTwoFactorSetupInlineCodes,
).copyWith(receiveTimeout: kSecuritySettingTimeout),
);
}
/// 모양이 틀린 응답 — 응답을 싣지 않고 Bearer 를 지운 실패
DioException _malformed(Response<Object?> response) => withoutSecrets(
apiFailure(response.requestOptions, kMalformedResponseCode),
);
}
@@ -0,0 +1,28 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/auth_interceptor.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 로그인 틀이 칸 · 상자에 보이지 않는 실패를 전역 알림(스낵바)으로 보인다 — 웹의 "그 밖의 실패는 알림"과 같은 결.
///
/// 서버가 답한 실패는 인터셉터가 이미 알렸다 — 같은 코드는 3초 안에 다시 띄우지 않아(`ErrorNotifier`) 겹치지 않는다.
/// 앱이 스스로 정한 실패(응답 모양이 틀림 · 받은 토큰을 보안 저장소에 두지 못함 — `SYS_001`)는 인터셉터를 지나지 않아 여기서만 알린다.
/// 문구는 사전에서만 온다(`ApiException.message`). 알리지 않는 것: `AUTH_010`(세션이 그 단계로 옮긴다) ·
/// `AUTH_002`(세션 끝 — 로그인 화면의 위쪽 상자가 알린다, 인터셉터의 알림과 같은 규칙).
/// 부르기 전에 화면이 그 시도가 아직 지금 세션의 것인지(`SessionNotifier.attempt`) 본다 — 끝난 세션의 늦은 실패는 보이지 않는다.
void showLoginFailure(WidgetRef ref, Object error) {
final ApiException? apiError = switch (error) {
ApiException() => error,
DioException() => toApiException(error),
_ => null,
};
if (apiError == null ||
apiError.code == kRestrictedSessionCode ||
apiError.code == kSessionExpiredCode) {
return;
}
ref.read(errorNotifierProvider).show(apiError);
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/utils/otp_code.dart';
import 'package:arasystem_app/features/auth/presentation/login_failures.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 로그인 둘째 단계 — 2단계 인증 코드(2단계를 켠 직원 — 앱 보드 `App1Login` 「2단계 인증 코드」 · 계약 5절 "로그인").
///
/// - 짙은 띠([다른 계정으로 로그인]) → 제목 · 설명 · 주소 → 코드 칸 → [확인] → 문의 줄 → 메일 프로그램 안내.
/// - 비밀번호는 세션 노티파이어 메모리에만 있다(10분) — 이 화면은 코드만 칸(화면 메모리)에 둔다.
/// - 6자리가 아니면 보내지 않고 칸 아래 `AUTH_007`. 서버의 `AUTH_006`(틀림 · 다시 쓴 코드)은 칸 아래 · 칸을 비운다.
/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). `AUTH_004`(그사이 비밀번호가 바뀜) · 10분 지남은
/// 세션이 첫 단계로 옮긴다(첫 단계가 까닭을 보인다). 그 밖은 전역 알림.
/// - [다른 계정으로 로그인]은 보내는 중에도 누를 수 있다 — 아직 로그인 전이라 서버에 바뀌는 것이 없다.
/// 늦게 온 결과는 세션 노티파이어가 버리고(시도 세대 — 토큰이 실려 오면 그 세션을 끝낸다), 이 화면도 보이지 않는다.
class LoginCodePage extends ConsumerStatefulWidget {
const LoginCodePage({super.key});
@override
ConsumerState<LoginCodePage> createState() => _LoginCodePageState();
}
class _LoginCodePageState extends ConsumerState<LoginCodePage> {
final TextEditingController _code = TextEditingController();
final FocusNode _codeFocus = FocusNode();
/// 비밀번호를 확인한 주소 — 화면이 사라지는 동안(첫 단계로 옮겨 가는 중)에도 같은 글을 보이게 처음 값을 둔다
late final String _email;
bool _submitting = false;
String? _codeError;
/// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구
String? _alert;
@override
void initState() {
super.initState();
final SessionState? session = ref.read(sessionProvider).value;
_email = session is CodeRequired ? session.email : '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
_codeFocus.requestFocus();
});
}
@override
void dispose() {
_code.clear();
_code.dispose();
_codeFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_submitting) return;
final String code = _code.text;
if (!isCompleteOtp(code)) {
setState(() {
_alert = null;
_codeError = ErrorLexicon.getMessage('AUTH_007');
});
_codeFocus.requestFocus();
return;
}
final SessionNotifier session = ref.read(sessionProvider.notifier);
final int attempt = session.attempt;
setState(() {
_submitting = true;
_codeError = null;
_alert = null;
});
try {
await session.submitCode(code);
// 세션이 다음 화면(제한 단계 · 메일 · 시간이 지났으면 첫 단계)으로 옮긴다 — 쓴 코드는 칸에 남기지 않는다
if (mounted) _code.clear();
} on DioException catch (error) {
// 그사이 [다른 계정으로 로그인] · 첫 단계로 돌아갔으면(`AUTH_004`) 이 화면은 보이지 않는다
if (!mounted ||
session.attempt != attempt ||
ref.read(sessionProvider).value is! CodeRequired) {
return;
}
_onFailure(error);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _onFailure(DioException error) {
final String message = toApiException(error).message;
switch (apiErrorCodeOf(error)) {
case 'AUTH_006':
case 'AUTH_007':
case 'VAL_001':
setState(() => _codeError = message);
_code.clear();
_codeFocus.requestFocus();
case 'REQ_001':
// 칸 · 단추는 그대로 — 잠시 뒤 같은 코드로 다시 보낼 수 있다
setState(() => _alert = message);
default:
showLoginFailure(ref, error);
}
}
void _setCode(String _) {
// 고치면 칸 아래 오류를 내린다(다른 칸과 같다)
if (_codeError != null) setState(() => _codeError = null);
}
@override
Widget build(BuildContext context) {
final String? alert = _alert;
return LoginStepFrame(
onOtherAccount: () =>
unawaited(ref.read(sessionProvider.notifier).switchAccount()),
children: <Widget>[
LoginHeading(
title: ScreenTitles.loginCode,
subtitle: LoginCodeTexts.subtitle,
email: _email,
),
if (alert != null) LoginAlertBox(message: alert),
LoginCodeField(
label: LoginCodeTexts.codeLabel,
controller: _code,
focusNode: _codeFocus,
error: _codeError,
textInputAction: TextInputAction.done,
onSubmitted: (_) => unawaited(_submit()),
onChanged: _setCode,
readOnly: _submitting,
),
LoginSubmitButton(
label: LoginCodeTexts.submit,
busyLabel: LoginCodeTexts.submitting,
busy: _submitting,
onPressed: () => unawaited(_submit()),
),
LoginSupportLine(
question: SupportLineTexts.authenticatorQuestion,
fallback: SupportLineTexts.authenticatorFallback,
contact: ref.watch(supportContactProvider),
),
const LoginNote(text: LoginCodeTexts.note),
],
);
}
}
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/api/token_refresher.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/utils/password_rules.dart';
import 'package:arasystem_app/features/auth/presentation/login_failures.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 로그인 첫 단계 — 주소 · 비밀번호(앱 보드 `App1Login` 「주소와 비밀번호」).
///
/// - 큰 브랜드 띠 → 제목 · 설명 → (위쪽 빨간 상자) → 메일 주소 · 비밀번호([보기]) → [로그인] → 문의 줄.
/// 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다 — 디자인 README).
/// - 비밀번호가 맞으면 세션이 다음 단계(코드 · 제한 단계 · 메일)로 옮긴다(라우터 redirect). 이 화면은 결과의 실패만 보인다.
/// - `AUTH_004` 는 위쪽 상자(사전 문구) + 두 칸 빨갛게 + 비밀번호 비우기, `VAL_001` 은 주소 칸 아래, 그 밖은 전역 알림.
/// 비운 칸도 보내지 않고 그 칸 아래에 `VAL_001` 사전 문구.
/// - 코드 단계에서 돌아왔으면(시간 지남 · 비밀번호 틀림) 주소를 남기고 위쪽 상자에 까닭을 보인다(세션 상태 `SignedOut.reason`),
/// 세션이 끝나 왔으면 `AUTH_002` 사전 문구.
/// - 비밀번호는 이 칸(화면 메모리)에만 있다 — 실패하면 비우고, 화면을 떠날 때 지운다.
/// 성공한 뒤 곧바로 비우지 않는 까닭: 다음 화면으로 넘어가며 이 칸 묶음(AutofillGroup)이 사라질 때 기기의 비밀번호 저장(자동 채우기)이 그 값을 본다.
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final TextEditingController _email = TextEditingController();
final TextEditingController _password = TextEditingController();
final FocusNode _emailFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
bool _passwordShown = false;
bool _submitting = false;
/// 위쪽 상자 — 사전 문구(`AUTH_004` · `AUTH_002`) 또는 코드 단계 시간 지남
String? _notice;
/// 주소 · 비밀번호가 맞지 않았다 — 두 칸을 빨갛게(글은 위쪽 상자)
bool _credentialsRejected = false;
String? _emailError;
String? _passwordError;
@override
void initState() {
super.initState();
final SessionState? session = ref.read(sessionProvider).value;
if (session is SignedOut) _applyArrival(session);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
// 주소가 남아 있으면 비밀번호부터 넣는다
if (_email.text.isNotEmpty) _passwordFocus.requestFocus();
});
}
/// 이 화면으로 온 까닭 — 주소를 남기고 위쪽 상자에 보일 글
void _applyArrival(SignedOut session) {
_email.text = session.email ?? '';
switch (session.reason) {
case SignOutReason.codeTimedOut:
_notice = LoginTexts.codeTimedOut;
case SignOutReason.passwordRejected:
_notice = ErrorLexicon.getMessage('AUTH_004');
_credentialsRejected = true;
case SignOutReason.expired:
_notice = ErrorLexicon.getMessage(kSessionExpiredCode);
case SignOutReason.none:
case SignOutReason.switchAccount:
break;
}
}
@override
void dispose() {
_password.clear();
_email.dispose();
_password.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_submitting) return;
final String email = _email.text.trim();
final String password = _password.text;
final String? emailProblem = email.isEmpty
? ErrorLexicon.getMessage('VAL_001')
: null;
final String? passwordProblem =
password.isEmpty || password.length > kLoginPasswordMaxLength
? ErrorLexicon.getMessage('VAL_001')
: null;
setState(() {
_notice = null;
_credentialsRejected = false;
_emailError = emailProblem;
_passwordError = passwordProblem;
});
if (emailProblem != null) {
_emailFocus.requestFocus();
return;
}
if (passwordProblem != null) {
_passwordFocus.requestFocus();
return;
}
setState(() => _submitting = true);
try {
await ref
.read(sessionProvider.notifier)
.signIn(email: email, password: password);
// 세션이 다음 화면으로 옮긴다
} on DioException catch (error) {
if (mounted) _onFailure(error);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _onFailure(DioException error) {
final String message = toApiException(error).message;
switch (apiErrorCodeOf(error)) {
case 'AUTH_004':
setState(() {
_notice = message;
_credentialsRejected = true;
});
_password.clear();
_passwordFocus.requestFocus();
case 'VAL_001':
setState(() => _emailError = message);
_emailFocus.requestFocus();
default:
showLoginFailure(ref, error);
}
}
@override
Widget build(BuildContext context) {
final String? notice = _notice;
return LoginBrandFrame(
children: <Widget>[
const LoginHeading(
title: ScreenTitles.login,
subtitle: LoginTexts.subtitle,
),
if (notice != null) LoginAlertBox(message: notice),
AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s16,
children: <Widget>[
LoginTextField(
label: LoginTexts.emailLabel,
controller: _email,
focusNode: _emailFocus,
placeholder: LoginTexts.emailPlaceholder,
error: _emailError,
invalid: _credentialsRejected,
// 위쪽 상자의 글을 칸에 이어 읽힌다(보드의 aria-describedby — 웹과 같이 두 칸 모두)
describedBy: notice,
keyboardType: TextInputType.emailAddress,
// 끄면 안드로이드 자판이 메일 주소 자판(@)이 아니게 된다 — 고쳐 쓰기(autocorrect)는 그대로 끈다
enableSuggestions: true,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
autofillHints: const <String>[AutofillHints.username],
),
LoginPasswordField(
label: LoginTexts.passwordLabel,
controller: _password,
focusNode: _passwordFocus,
placeholder: LoginTexts.passwordPlaceholder,
shown: _passwordShown,
onToggle: () =>
setState(() => _passwordShown = !_passwordShown),
toggleLabel: LoginTexts.showPassword,
error: _passwordError,
invalid: _credentialsRejected,
describedBy: notice,
textInputAction: TextInputAction.done,
onSubmitted: (_) => unawaited(_submit()),
autofillHints: const <String>[AutofillHints.password],
),
LoginSubmitButton(
label: LoginTexts.submit,
busyLabel: LoginTexts.submitting,
busy: _submitting,
onPressed: () => unawaited(_submit()),
inForm: true,
),
],
),
),
LoginSupportLine(
question: SupportLineTexts.passwordQuestion,
fallback: SupportLineTexts.passwordFallback,
contact: ref.watch(supportContactProvider),
),
],
);
}
}
@@ -0,0 +1,436 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/utils/otp_code.dart';
import 'package:arasystem_app/core/utils/password_rules.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart';
import 'package:arasystem_app/features/auth/presentation/login_failures.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 로그인 셋째 단계 — 새 비밀번호 정하기(제한 `passwordChange` — 앱 보드 `App1Login` 「새 비밀번호 정하기」 · 계약 6절 "비밀번호 바꾸기 강제").
///
/// - 짙은 띠 → 제목 · 설명 · 주소 → (위쪽 상자) → (임시 비밀번호) · 새 비밀번호([보기]) · 확인 · (인증 앱 코드) → [비밀번호 바꾸고 계속] → 안내.
/// - 임시 비밀번호는 로그인 때 넣은 것을 세션 노티파이어가 쥐고 있다(10분) — 없을 때만(앱을 다시 열었다 · 10분 지남) 칸을 보인다.
/// - 코드 칸은 2단계 인증을 켠 직원만 — 코드 단계를 거쳐 왔거나 `GET /two-factor` 가 켜짐이거나 서버가 `AUTH_007` 로 코드를 요구했을 때(웹과 같다).
/// - 보내기 전에 서버와 같은 규칙을 본다(8자 이상 · 임시 비밀번호와 다름 · 확인 일치 · 128자). 서버 거절은 칸 아래에 —
/// `AUTH_005` 임시 비밀번호 칸(쥔 것이었으면 지우고 칸을 보인다) · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸.
/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). 그 밖은 전역 알림.
/// - 바뀌면 칸을 비우고 세션 노티파이어가 새 토큰을 받아 다음 단계(2단계 켜기) · 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] —
/// 그동안 칸 · 단추는 막는다(이미 바꿨다).
/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다) — 서버가 이미 바꿨을 수 있는데 그 결과를 버리면
/// 직원이 임시 비밀번호로 다시 들어가다 막힌다(`AUTH_004`).
class PasswordChangePage extends ConsumerStatefulWidget {
const PasswordChangePage({super.key});
@override
ConsumerState<PasswordChangePage> createState() => _PasswordChangePageState();
}
class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
final TextEditingController _temporary = TextEditingController();
final TextEditingController _next = TextEditingController();
final TextEditingController _confirm = TextEditingController();
final TextEditingController _code = TextEditingController();
final FocusNode _temporaryFocus = FocusNode();
final FocusNode _nextFocus = FocusNode();
final FocusNode _confirmFocus = FocusNode();
final FocusNode _codeFocus = FocusNode();
/// 로그인 때 넣은 비밀번호(임시 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 칸을 보인다
late final ValueListenable<bool> _held;
late final String _email;
/// 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원
late final bool _passedCodeStep;
/// `GET /two-factor` 가 켜짐이다
bool _twoFactorOn = false;
/// 서버가 코드를 요구했다(`AUTH_007` — 그사이 다른 곳에서 켬)
bool _codeRequired = false;
bool _temporaryShown = false;
bool _nextShown = false;
bool _submitting = false;
bool _finishing = false;
/// 바꿨지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도])
String? _finishError;
/// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구
String? _alert;
PasswordChangeProblems _problems = const PasswordChangeProblems();
String? _temporaryError;
String? _nextError;
String? _codeError;
bool get _showCode => _passedCodeStep || _twoFactorOn || _codeRequired;
bool get _busy => _submitting || _finishing;
/// 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다)
bool get _blocked => _busy || _finishError != null;
@override
void initState() {
super.initState();
final SessionNotifier session = ref.read(sessionProvider.notifier);
_held = session.heldLoginListenable;
_held.addListener(_onHeldChanged);
_passedCodeStep = session.passedCodeStep;
final SessionState? state = ref.read(sessionProvider).value;
_email = state is SignedIn ? state.info.email : '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
(_held.value ? _nextFocus : _temporaryFocus).requestFocus();
// 코드 단계를 거치지 않았으면(앱을 다시 열었다) 2단계 인증 상태를 묻는다 — 못 받아도 서버의 AUTH_007 이 칸을 부른다
if (!_passedCodeStep) unawaited(_loadTwoFactorStatus());
});
}
@override
void dispose() {
_held.removeListener(_onHeldChanged);
_clearFields();
for (final TextEditingController controller in <TextEditingController>[
_temporary,
_next,
_confirm,
_code,
]) {
controller.dispose();
}
for (final FocusNode node in <FocusNode>[
_temporaryFocus,
_nextFocus,
_confirmFocus,
_codeFocus,
]) {
node.dispose();
}
super.dispose();
}
/// 쥔 임시 비밀번호가 사라졌다(10분 · 서버 거절) — 칸을 보인다
void _onHeldChanged() {
if (mounted) setState(() {});
}
Future<void> _loadTwoFactorStatus() async {
final SessionNotifier session = ref.read(sessionProvider.notifier);
final int attempt = session.attempt;
try {
final TwoFactorStatus status = await ref
.read(twoFactorRepositoryProvider)
.status();
if (!mounted || session.attempt != attempt) return;
if (status.isEnabled) setState(() => _twoFactorOn = true);
} on DioException {
// 못 받았다 — 서버가 AUTH_007 로 코드를 요구하면 그때 칸을 보인다
}
}
void _clearFields() {
_temporary.clear();
_next.clear();
_confirm.clear();
_code.clear();
}
void _focusLater(FocusNode node) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) node.requestFocus();
});
}
Future<void> _submit() async {
if (_blocked) return;
final SessionNotifier session = ref.read(sessionProvider.notifier);
final ({String email, String password})? held = session.heldLogin;
final PasswordChangeProblems problems = passwordChangeProblems(
current: held?.password ?? _temporary.text,
next: _next.text,
confirm: _confirm.text,
);
final bool codeMissing = _showCode && !isCompleteOtp(_code.text);
setState(() {
_alert = null;
_problems = problems;
_temporaryError = null;
_nextError = problems.next == NewPasswordProblem.tooLong
? ErrorLexicon.getMessage('VAL_001')
: null;
_codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null;
});
if (problems.hasAny) {
// 10분이 막 지나 임시 비밀번호 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
_focusLater(
problems.currentMissing
? _temporaryFocus
: problems.next != null
? _nextFocus
: _confirmFocus,
);
return;
}
if (codeMissing) {
_codeFocus.requestFocus();
return;
}
final int attempt = session.attempt;
setState(() => _submitting = true);
final RestrictedStepResult result;
try {
result = await session.changePassword(
newPassword: _next.text,
currentPassword: held == null ? _temporary.text : null,
otpCode: _showCode ? _code.text : null,
);
} on DioException catch (error) {
if (mounted && session.attempt == attempt) _onFailure(error);
return;
} finally {
if (mounted) setState(() => _submitting = false);
}
if (!mounted) return;
switch (result) {
case RestrictedStepResult.stale:
return;
case RestrictedStepResult.needsPassword:
// 쥔 비밀번호가 그사이 사라졌다 — 칸을 보이고 안내 글을 빨갛게
setState(
() => _problems = const PasswordChangeProblems(currentMissing: true),
);
_focusLater(_temporaryFocus);
return;
case RestrictedStepResult.done:
setState(() {
_clearFields();
_problems = const PasswordChangeProblems();
});
}
await _finish();
}
void _onFailure(DioException error) {
final String message = toApiException(error).message;
switch (apiErrorCodeOf(error)) {
case 'AUTH_005':
// 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — 임시 비밀번호 칸이 이 오류와 함께 보인다
setState(() => _temporaryError = message);
_focusLater(_temporaryFocus);
case 'VAL_001':
setState(() => _nextError = message);
_nextFocus.requestFocus();
case 'AUTH_006':
setState(() => _codeError = message);
_code.clear();
_codeFocus.requestFocus();
case 'AUTH_007':
// 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보인다
setState(() {
_codeRequired = true;
_codeError = message;
});
_code.clear();
_focusLater(_codeFocus);
case 'REQ_001':
// 칸 · 단추는 그대로 — 잠시 뒤 다시 보낼 수 있다
setState(() => _alert = message);
default:
showLoginFailure(ref, error);
}
}
/// 바꾼 뒤 — 새 토큰을 받아 다음 단계로. 넘기지 못하면 위쪽 상자 + [다시 시도]
Future<void> _finish() async {
final SessionNotifier session = ref.read(sessionProvider.notifier);
final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf(
context,
);
setState(() {
_finishing = true;
_finishError = null;
});
try {
final RestrictedStepResult result = await session.finishRestrictedStep();
if (result == RestrictedStepResult.done) {
messenger?.showSnackBar(
const SnackBar(content: Text(PasswordChangeTexts.done)),
);
}
} on DioException catch (error) {
if (mounted) {
setState(() => _finishError = toApiException(error).message);
}
} finally {
if (mounted) setState(() => _finishing = false);
}
}
// 칸을 고치면 그 칸에 걸린 오류를 내린다(임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다 — 웹과 같다)
void _onTemporaryChanged(String _) {
if (!_problems.currentMissing &&
_temporaryError == null &&
_problems.next != NewPasswordProblem.rule) {
return;
}
setState(() {
_problems = PasswordChangeProblems(
next: _problems.next == NewPasswordProblem.rule ? null : _problems.next,
confirmMismatch: _problems.confirmMismatch,
);
_temporaryError = null;
});
}
void _onNextChanged(String _) {
if (_problems.next == null &&
!_problems.confirmMismatch &&
_nextError == null) {
return;
}
setState(() {
_problems = PasswordChangeProblems(
currentMissing: _problems.currentMissing,
);
_nextError = null;
});
}
void _onConfirmChanged(String _) {
if (!_problems.confirmMismatch) return;
setState(() {
_problems = PasswordChangeProblems(
currentMissing: _problems.currentMissing,
next: _problems.next,
);
});
}
void _onCodeChanged(String _) {
if (_codeError != null) setState(() => _codeError = null);
}
@override
Widget build(BuildContext context) {
final bool askTemporary = !_held.value;
final String? finishError = _finishError;
final String? alert = _alert;
final bool showCode = _showCode;
return LoginStepFrame(
onOtherAccount: () =>
unawaited(ref.read(sessionProvider.notifier).switchAccount()),
otherAccountEnabled: !_busy,
children: <Widget>[
LoginHeading(
title: ScreenTitles.passwordChange,
subtitle: PasswordChangeTexts.subtitle,
email: _email,
),
if (finishError != null)
LoginAlertBox(
message: finishError,
actionLabel: RestrictedStepTexts.retry,
onAction: _busy ? null : () => unawaited(_finish()),
)
else if (alert != null)
LoginAlertBox(message: alert),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s16,
children: <Widget>[
if (askTemporary)
LoginPasswordField(
label: PasswordChangeTexts.temporaryLabel,
controller: _temporary,
focusNode: _temporaryFocus,
shown: _temporaryShown,
onToggle: () =>
setState(() => _temporaryShown = !_temporaryShown),
toggleLabel: PasswordChangeTexts.showTemporary,
hint: PasswordChangeTexts.temporaryHint,
hintIsError: _problems.currentMissing,
error: _temporaryError,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _nextFocus.requestFocus(),
onChanged: _onTemporaryChanged,
autofillHints: const <String>[AutofillHints.password],
readOnly: _blocked,
),
LoginPasswordField(
label: PasswordChangeTexts.nextLabel,
controller: _next,
focusNode: _nextFocus,
shown: _nextShown,
onToggle: () => setState(() => _nextShown = !_nextShown),
toggleLabel: PasswordChangeTexts.showNext,
hint: PasswordChangeTexts.nextHint,
hintIsError: _problems.next == NewPasswordProblem.rule,
error: _nextError,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _confirmFocus.requestFocus(),
onChanged: _onNextChanged,
autofillHints: const <String>[AutofillHints.newPassword],
readOnly: _blocked,
),
LoginTextField(
label: PasswordChangeTexts.confirmLabel,
controller: _confirm,
focusNode: _confirmFocus,
// 보기 단추 없이 새 비밀번호의 [보기]를 따른다
obscureText: !_nextShown,
keyboardType: TextInputType.visiblePassword,
error: _problems.confirmMismatch
? PasswordChangeTexts.mismatch
: null,
textInputAction: showCode
? TextInputAction.next
: TextInputAction.done,
onSubmitted: (_) =>
showCode ? _codeFocus.requestFocus() : unawaited(_submit()),
onChanged: _onConfirmChanged,
autofillHints: const <String>[AutofillHints.newPassword],
readOnly: _blocked,
),
if (showCode)
LoginCodeField(
label: PasswordChangeTexts.codeLabel,
controller: _code,
focusNode: _codeFocus,
hint: PasswordChangeTexts.codeHint,
error: _codeError,
textInputAction: TextInputAction.done,
onSubmitted: (_) => unawaited(_submit()),
onChanged: _onCodeChanged,
readOnly: _blocked,
),
LoginSubmitButton(
label: PasswordChangeTexts.submit,
busyLabel: PasswordChangeTexts.submitting,
busy: _busy,
enabled: finishError == null,
onPressed: () => unawaited(_submit()),
inForm: true,
),
],
),
const LoginNote(text: PasswordChangeTexts.note),
],
);
}
}
@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:arasystem_app/core/api/auth_interceptor.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱이 모르는 제한(서버가 새 제한을 더했을 때) — `AUTH_010` 사전 문구와 [다른 계정으로 로그인]만(웹과 같다 — 다시 받기 · 다시 보내기를 되풀이하지 않는다).
///
/// 제목이 없는 화면이라 사전 문구 상자가 화면 이름이다(단계가 바뀌면 화면 낭독기가 읽는다).
class RestrictedLoginPage extends ConsumerWidget {
const RestrictedLoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final SessionState? state = ref.watch(sessionProvider).value;
final String? email = state is SignedIn ? state.info.email : null;
return LoginStepFrame(
onOtherAccount: () =>
unawaited(ref.read(sessionProvider.notifier).switchAccount()),
children: <Widget>[
if (email != null) LoginAccountChip(email: email),
LoginAlertBox(
message: ErrorLexicon.getMessage(kRestrictedSessionCode),
namesRoute: true,
),
],
);
}
}
@@ -0,0 +1,633 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/display_text.dart';
import 'package:arasystem_app/core/utils/otp_code.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart';
import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart';
import 'package:arasystem_app/features/auth/presentation/login_failures.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 키를 받는 상태
enum _EnrollmentLoad { loading, ready, failed }
/// 로그인 넷째 단계 — 2단계 인증 켜기(제한 `twoFactorSetup` — 앱 보드 `App1Login` 「2단계 인증 켜기」 · 계약 6절 "2단계 인증 필수").
///
/// - 같은 휴대폰이라 QR 대신 ① [인증 앱에 추가](otpauth 주소로 인증 앱을 바로 연다) · 키와 [복사] ② 코드 칸 (③ 지금 비밀번호 — 쥐고 있지 않을 때만).
/// - 키는 이 화면을 열 때마다 새로 받는다(`POST /two-factor/enrollments` — 10분 안에 켠다). **키 · otpauth 주소는 이 화면의 메모리에만** 두고
/// 켜거나 떠나면 버린다(Riverpod 상태 · 로그에 두지 않는다). [복사]한 키는 클립보드에 남는다 — 앱이 지우지 않는다.
/// - 인증 앱을 찾지 못하면(열 앱 없음) ① 아래에 안내 글 — 키를 직접 넣는 길이 바로 아래에 있다.
/// - 서버 거절: `AUTH_006` 코드 칸(비우고 초점 — 키는 그대로), `AUTH_005` 지금 비밀번호 칸(쥔 것이었으면 지우고 ③ 을 보인다),
/// `AUTH_008`(시간 지남 · 그사이 바뀜) 위쪽 상자 + 새 키(다른 곳에서 이미 켰으면 이 단계를 건너뛴다), 시도 과다(`REQ_001`) 위쪽 상자.
/// 그 밖은 전역 알림 — 서버의 `VAL_001` 도(코드는 보내기 전에 6자리를 보므로 지금 비밀번호 쪽이다 · 맞는 코드는 지우지 않는다, 웹과 같다).
/// - 켜면 세션 노티파이어가 새 토큰을 받아 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — 그동안 칸 · 단추는 막는다(이미 켰다).
/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다 — 서버가 이미 켰을 수 있다).
class TwoFactorSetupPage extends ConsumerStatefulWidget {
const TwoFactorSetupPage({super.key});
@override
ConsumerState<TwoFactorSetupPage> createState() => _TwoFactorSetupPageState();
}
class _TwoFactorSetupPageState extends ConsumerState<TwoFactorSetupPage> {
final TextEditingController _code = TextEditingController();
final TextEditingController _password = TextEditingController();
final FocusNode _codeFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
/// 로그인 때 넣은 비밀번호(또는 방금 정한 새 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 ③ 칸
late final ValueListenable<bool> _held;
late final String _email;
/// 등록 — 키 · otpauth 주소(비밀). 이 화면의 메모리에만
TwoFactorEnrollment? _enrollment;
_EnrollmentLoad _load = _EnrollmentLoad.loading;
/// 키를 받지 못한 까닭 — 사전 문구
String? _loadError;
/// 키를 받는 차례 — 다시 받으면 앞의 응답은 버린다
int _loadSeq = 0;
/// [인증 앱에 추가]로 열 앱을 찾지 못했다
bool _openFailed = false;
bool _passwordShown = false;
bool _submitting = false;
bool _finishing = false;
/// 위쪽 상자 — 처음부터 다시(`AUTH_008`) · 시도 과다(`REQ_001`) 사전 문구. 다음에 보낼 때 내린다
String? _alert;
/// 켰지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도])
String? _finishError;
String? _codeError;
String? _passwordError;
/// ③ 을 비우고 보냈다 — 안내 글이 빨간 오류가 된다
bool _passwordMissing = false;
/// 켰다(또는 다른 곳에서 이미 켰다) — 키 · 칸은 쓸 일이 없다. 넘기지 못했으면 위쪽 상자와 [다시 시도]만 남긴다
bool _enabled = false;
bool get _busy => _submitting || _finishing;
/// 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다
bool get _blocked => _busy || _finishError != null;
@override
void initState() {
super.initState();
final SessionNotifier session = ref.read(sessionProvider.notifier);
_held = session.heldLoginListenable;
_held.addListener(_onHeldChanged);
final SessionState? state = ref.read(sessionProvider).value;
_email = state is SignedIn ? state.info.email : '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
unawaited(_loadEnrollment());
});
}
@override
void dispose() {
_held.removeListener(_onHeldChanged);
// 비밀을 버린다 — 키 · otpauth 주소 · 코드 · 비밀번호
_loadSeq++;
_enrollment = null;
_code.clear();
_password.clear();
_code.dispose();
_password.dispose();
_codeFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
void _onHeldChanged() {
if (mounted) setState(() {});
}
/// 이 화면이 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지
bool _stillHere(int attempt) =>
mounted && ref.read(sessionProvider.notifier).attempt == attempt;
Future<void> _loadEnrollment() async {
final int seq = ++_loadSeq;
final int attempt = ref.read(sessionProvider.notifier).attempt;
setState(() {
_enrollment = null;
_load = _EnrollmentLoad.loading;
_loadError = null;
_openFailed = false;
});
try {
final TwoFactorEnrollment enrollment = await ref
.read(twoFactorRepositoryProvider)
.startEnrollment();
if (seq != _loadSeq || !_stillHere(attempt)) return;
setState(() {
_enrollment = enrollment;
_load = _EnrollmentLoad.ready;
});
} on DioException catch (error) {
if (seq != _loadSeq || !_stillHere(attempt)) return;
// 이미 켜져 있다 — 다른 곳에서 켰다
if (apiErrorCodeOf(error) == 'AUTH_008' &&
await _skipIfEnabledElsewhere(attempt)) {
return;
}
if (seq != _loadSeq || !_stillHere(attempt)) return;
setState(() {
_load = _EnrollmentLoad.failed;
_loadError = toApiException(error).message;
});
}
}
/// 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true)
Future<bool> _skipIfEnabledElsewhere(int attempt) async {
final TwoFactorStatus status;
try {
status = await ref.read(twoFactorRepositoryProvider).status();
} on DioException {
return false;
}
if (!status.isEnabled) return false;
if (_stillHere(attempt)) {
setState(() {
_enabled = true;
_loadSeq++;
_enrollment = null;
_code.clear();
_password.clear();
});
await _finish(announce: false);
}
return true;
}
Future<void> _openAuthenticator() async {
final TwoFactorEnrollment? enrollment = _enrollment;
if (enrollment == null) return;
final bool opened = await ref
.read(authenticatorLauncherProvider)
.open(enrollment.otpauthUrl);
if (mounted) setState(() => _openFailed = !opened);
}
Future<void> _copyKey() async {
final TwoFactorEnrollment? enrollment = _enrollment;
if (enrollment == null) return;
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
bool copied = true;
try {
// 키를 띄어쓰기 없이 — 클립보드에 남는다(앱이 지우지 않는다)
await Clipboard.setData(ClipboardData(text: enrollment.secret));
} on PlatformException {
copied = false;
}
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
copied
? TwoFactorSetupTexts.copied
: TwoFactorSetupTexts.copyFailed,
),
),
);
}
Future<void> _submit() async {
final TwoFactorEnrollment? enrollment = _enrollment;
if (_blocked || enrollment == null) return;
final SessionNotifier session = ref.read(sessionProvider.notifier);
final bool held = session.heldLogin != null;
final bool codeMissing = !isCompleteOtp(_code.text);
final bool passwordMissing = !held && _password.text.isEmpty;
setState(() {
_alert = null;
_codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null;
_passwordError = null;
_passwordMissing = passwordMissing;
});
if (codeMissing) {
_codeFocus.requestFocus();
return;
}
if (passwordMissing) {
// 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점
_focusLater(_passwordFocus);
return;
}
final int attempt = session.attempt;
setState(() => _submitting = true);
final RestrictedStepResult result;
try {
result = await session.enableTwoFactor(
enrollmentId: enrollment.enrollmentId,
otpCode: _code.text,
currentPassword: held ? null : _password.text,
);
} on DioException catch (error) {
if (_stillHere(attempt)) await _onFailure(error, attempt);
return;
} finally {
if (mounted) setState(() => _submitting = false);
}
if (!mounted) return;
switch (result) {
case RestrictedStepResult.stale:
return;
case RestrictedStepResult.needsPassword:
setState(() => _passwordMissing = true);
_focusLater(_passwordFocus);
return;
case RestrictedStepResult.done:
// 켰다 — 다시 쓸 일이 없는 비밀(키 · 코드 · 비밀번호)을 버린다
setState(() {
_enabled = true;
_loadSeq++;
_enrollment = null;
_code.clear();
_password.clear();
});
}
await _finish(announce: true);
}
Future<void> _onFailure(DioException error, int attempt) async {
final String message = toApiException(error).message;
switch (apiErrorCodeOf(error)) {
case 'AUTH_006':
case 'AUTH_007':
setState(() => _codeError = message);
_code.clear();
_codeFocus.requestFocus();
case 'REQ_001':
// 칸 · 단추 · 키는 그대로 — 잠시 뒤 다시 보낼 수 있다
setState(() => _alert = message);
case 'AUTH_005':
// 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — ③ 칸이 이 오류와 함께 보인다
setState(() => _passwordError = message);
_focusLater(_passwordFocus);
case 'AUTH_008':
if (await _skipIfEnabledElsewhere(attempt)) return;
if (!_stillHere(attempt)) return;
setState(() {
_alert = message;
_codeError = null;
_passwordError = null;
});
_code.clear();
await _loadEnrollment();
default:
showLoginFailure(ref, error);
}
}
/// 켠 뒤 — 새 토큰을 받아 메일로. 넘기지 못하면 위쪽 상자 + [다시 시도]
Future<void> _finish({required bool announce}) async {
final SessionNotifier session = ref.read(sessionProvider.notifier);
final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf(
context,
);
setState(() {
_finishing = true;
_finishError = null;
});
try {
final RestrictedStepResult result = await session.finishRestrictedStep();
if (announce && result == RestrictedStepResult.done) {
messenger?.showSnackBar(
const SnackBar(content: Text(TwoFactorSetupTexts.done)),
);
}
} on DioException catch (error) {
if (mounted) {
setState(() => _finishError = toApiException(error).message);
}
} finally {
if (mounted) setState(() => _finishing = false);
}
}
void _focusLater(FocusNode node) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) node.requestFocus();
});
}
void _onCodeChanged(String _) {
if (_codeError != null) setState(() => _codeError = null);
}
void _onPasswordChanged(String _) {
if (_passwordError != null || _passwordMissing) {
setState(() {
_passwordError = null;
_passwordMissing = false;
});
}
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final TextStyle? stepTitle = theme.textTheme.titleSmall;
// 켠 뒤에는 쥔 비밀번호를 지웠다 — ③ 을 다시 보이지 않는다
final bool askPassword = !_held.value && !_enabled;
final String? finishError = _finishError;
final String? alert = _alert;
// 켰지만 넘기지 못했다 — 키 · 칸 · 단추는 쓸 일이 없어 위쪽 상자와 [다시 시도]만 남긴다
final bool onlyRetry = _enabled && finishError != null;
return LoginStepFrame(
topPadding: AppSpacing.s24,
onOtherAccount: () =>
unawaited(ref.read(sessionProvider.notifier).switchAccount()),
otherAccountEnabled: !_busy,
children: <Widget>[
LoginHeading(
title: ScreenTitles.twoFactorSetup,
subtitle: TwoFactorSetupTexts.subtitle,
email: _email,
),
if (finishError != null)
LoginAlertBox(
message: finishError,
actionLabel: RestrictedStepTexts.retry,
onAction: _busy ? null : () => unawaited(_finish(announce: true)),
)
else if (alert != null)
LoginAlertBox(message: alert),
if (!onlyRetry) ...<Widget>[
_steps(stepTitle, askPassword),
LoginSubmitButton(
label: TwoFactorSetupTexts.submit,
busyLabel: TwoFactorSetupTexts.submitting,
busy: _busy,
enabled: _load == _EnrollmentLoad.ready && finishError == null,
onPressed: () => unawaited(_submit()),
),
],
LoginSupportLine(
question: SupportLineTexts.authenticatorQuestion,
fallback: SupportLineTexts.authenticatorFallback,
contact: ref.watch(supportContactProvider),
),
const LoginNote(text: TwoFactorSetupTexts.note),
],
);
}
/// 차례 ① 인증 앱에 추가 · ② 코드 · (③ 지금 비밀번호)
Widget _steps(TextStyle? stepTitle, bool askPassword) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s22,
children: <Widget>[
LoginStepItem(
number: 1,
child: _AddToAuthenticator(
email: _email,
enrollment: _enrollment,
load: _load,
loadError: _loadError,
openFailed: _openFailed,
enabled: !_blocked,
onOpen: () => unawaited(_openAuthenticator()),
onCopy: () => unawaited(_copyKey()),
onRetry: () => unawaited(_loadEnrollment()),
),
),
LoginStepItem(
number: 2,
child: LoginCodeField(
label: TwoFactorSetupTexts.codeLabel,
labelStyle: stepTitle,
description: TwoFactorSetupTexts.codeHint,
controller: _code,
focusNode: _codeFocus,
error: _codeError,
textInputAction: askPassword
? TextInputAction.next
: TextInputAction.done,
onSubmitted: (_) => askPassword
? _passwordFocus.requestFocus()
: unawaited(_submit()),
onChanged: _onCodeChanged,
readOnly: _blocked,
),
),
if (askPassword)
LoginStepItem(
number: 3,
child: LoginPasswordField(
label: TwoFactorSetupTexts.passwordLabel,
controller: _password,
focusNode: _passwordFocus,
shown: _passwordShown,
onToggle: () => setState(() => _passwordShown = !_passwordShown),
toggleLabel: TwoFactorSetupTexts.showPassword,
hint: TwoFactorSetupTexts.passwordHint,
hintIsError: _passwordMissing,
error: _passwordError,
textInputAction: TextInputAction.done,
onSubmitted: (_) => unawaited(_submit()),
onChanged: _onPasswordChanged,
autofillHints: const <String>[AutofillHints.password],
readOnly: _blocked,
),
),
],
);
}
}
/// ① 인증 앱에 추가 — [인증 앱에 추가] · (찾지 못함 안내) · 키와 [복사] · 저장될 이름. 키를 받는 동안은 회색 자리, 못 받으면 사전 문구 + [다시 시도]
class _AddToAuthenticator extends StatelessWidget {
const _AddToAuthenticator({
required this.email,
required this.enrollment,
required this.load,
required this.loadError,
required this.openFailed,
required this.enabled,
required this.onOpen,
required this.onCopy,
required this.onRetry,
});
final String email;
final TwoFactorEnrollment? enrollment;
final _EnrollmentLoad load;
final String? loadError;
final bool openFailed;
/// 켜는 중 · 이미 켰으면 누를 수 없다
final bool enabled;
final VoidCallback onOpen;
final VoidCallback onCopy;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? error = loadError;
final bool ready = enrollment != null && enabled;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s10,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s4,
children: <Widget>[
Semantics(
header: true,
child: KeepAllText(
TwoFactorSetupTexts.addTitle,
style: theme.textTheme.titleSmall,
),
),
KeepAllText(
TwoFactorSetupTexts.addHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (load == _EnrollmentLoad.failed && error != null)
LoginAlertBox(
message: error,
actionLabel: TwoFactorSetupTexts.retry,
onAction: onRetry,
)
else ...<Widget>[
OutlinedButton.icon(
onPressed: ready ? onOpen : null,
icon: const Icon(Icons.open_in_new, size: AppSize.iconSmall),
label: const Text(TwoFactorSetupTexts.addButton),
),
if (openFailed)
const LoginFieldMessage(
text: TwoFactorSetupTexts.addFailed,
isError: true,
),
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s6,
children: <Widget>[
KeepAllText(
TwoFactorSetupTexts.keyLabel,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textStrong,
),
),
_KeyBox(enrollment: enrollment, onCopy: ready ? onCopy : null),
KeepAllText(
TwoFactorSetupTexts.savedAs(email),
style: theme.textTheme.labelSmall?.copyWith(
color: extra.textMuted,
),
),
],
),
),
],
],
);
}
}
/// 키 상자 — 4자씩 · 두 줄 · 고정폭(보기 위한 띄어쓰기) + [복사](키를 띄어쓰기 없이). 받는 동안은 회색 자리
class _KeyBox extends StatelessWidget {
const _KeyBox({required this.enrollment, required this.onCopy});
final TwoFactorEnrollment? enrollment;
final VoidCallback? onCopy;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final TextStyle? base = theme.textTheme.bodyMedium;
final double fontSize = base?.fontSize ?? AppTypography.bodyMedium;
final TextStyle keyStyle = TextStyle(
fontFamily: AppTypography.monospaceFamily,
fontSize: fontSize,
height: AppTypography.lineHeightRelaxed,
letterSpacing: fontSize * AppTypography.letterSpacingKeyEm,
color: scheme.onSurface,
);
final TwoFactorEnrollment? current = enrollment;
final Widget key = current == null
// 키를 받는 중 — 두 줄 높이의 회색 자리
? Semantics(
label: TwoFactorSetupTexts.keyLoading,
child: Container(
height: fontSize * AppTypography.lineHeightRelaxed * 2,
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
)
: Text(keyLines(current.secret).join('\n'), style: keyStyle);
return Container(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s12,
AppSpacing.s4,
AppSpacing.s2,
AppSpacing.s4,
),
decoration: BoxDecoration(
color: scheme.surfaceContainer,
border: Border.all(color: scheme.outlineVariant),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
spacing: AppSpacing.s4,
children: <Widget>[
Expanded(child: key),
TextButton.icon(
onPressed: onCopy,
icon: const Icon(
Icons.content_copy_outlined,
size: AppSize.iconSmall,
),
label: const Text(
TwoFactorSetupTexts.copy,
semanticsLabel: TwoFactorSetupTexts.copyLabel,
),
),
],
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart';
import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 2단계 인증 API — 로그인 틀의 제한 단계가 쓴다
final Provider<TwoFactorRepository> twoFactorRepositoryProvider =
Provider<TwoFactorRepository>(
(Ref ref) => TwoFactorRepository(ref.watch(dioProvider)),
);
/// 문의 연락처 API
final Provider<SupportContactRepository> supportContactRepositoryProvider =
Provider<SupportContactRepository>(
(Ref ref) => SupportContactRepository(ref.watch(dioProvider)),
);
/// [인증 앱에 추가] — 시험은 가짜로 덮어쓴다
final Provider<AuthenticatorLauncher> authenticatorLauncherProvider =
Provider<AuthenticatorLauncher>((Ref ref) => const AuthenticatorLauncher());
/// 문의 연락처 한 줄 — 로그인 첫 화면 · 코드 단계 · 2단계 켜기의 문의 줄. 정하지 않았거나 받지 못했으면 null(화면은 지금 글).
///
/// 화면이 [SupportContactNotifier.ensureLoaded] 를 부른다. 한 번 받으면 앱이 떠 있는 동안 다시 묻지 않고(운영 도구로 바꾸면 다음 실행에 반영),
/// 받지 못했으면 다음에 화면을 열 때 다시 묻는다(웹 `support-contact.store` 와 같다). 회사 설정값이라 로그아웃 때 지우지 않는다.
final NotifierProvider<SupportContactNotifier, String?> supportContactProvider =
NotifierProvider<SupportContactNotifier, String?>(
SupportContactNotifier.new,
retry: noAutomaticRetry,
);
class SupportContactNotifier extends Notifier<String?> {
Future<void>? _loading;
bool _loaded = false;
@override
String? build() => null;
/// 아직 받지 않았으면 받는다 — 몇 번을 불러도 요청은 하나
Future<void> ensureLoaded() {
if (_loaded) return Future<void>.value();
return _loading ??= _load();
}
Future<void> _load() async {
try {
final String? text = await ref
.read(supportContactRepositoryProvider)
.fetch();
if (!ref.mounted) return;
state = text;
_loaded = true;
} on DioException {
// 받지 못했다 — 지금 글 그대로(다음에 화면을 열 때 다시 묻는다)
} finally {
_loading = null;
}
}
}
@@ -1,15 +1,17 @@
import 'dart:async';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:flutter/foundation.dart';
/// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계)
const Duration kLoginSecretLifetime = Duration(minutes: 10);
/// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다.
///
/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다).
/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분.
/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다) · 2단계 켜기(지금 비밀번호 — 새 비밀번호를 정했으면 그것).
/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 거절함(`AUTH_005`) · 서버가 마지막으로 확인하고 10분.
/// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다.
/// 쥐고 있는지([held] — 참 · 거짓 하나, 비밀이 아니다)는 화면이 듣는다: 없으면 임시 · 지금 비밀번호 칸을 보인다.
class LoginSecret {
LoginSecret({
Clock clock = systemClock,
@@ -19,6 +21,7 @@ class LoginSecret {
final Clock _clock;
final Duration _lifetime;
final ValueNotifier<bool> _held = ValueNotifier<bool>(false);
String? _email;
String? _password;
@@ -27,10 +30,11 @@ class LoginSecret {
/// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다
void hold(String email, String password) {
clear();
_forget();
_email = email;
_password = password;
renew();
_held.value = true;
}
/// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다
@@ -57,7 +61,15 @@ class LoginSecret {
/// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다)
bool get isHeld => _password != null;
/// 쥐고 있는지가 바뀔 때 알린다 — 화면이 칸을 보일지 정한다(값은 참 · 거짓뿐)
ValueListenable<bool> get held => _held;
void clear() {
_forget();
_held.value = false;
}
void _forget() {
_timer?.cancel();
_timer = null;
_email = null;
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/session_events.dart';
@@ -13,9 +14,12 @@ import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/auth/data/models/app_login_result.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart';
import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -38,18 +42,31 @@ final AsyncNotifierProvider<SessionNotifier, SessionState> sessionProvider =
retry: noAutomaticRetry,
);
/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기.
/// 제한 단계(새 비밀번호 · 2단계 켜기)의 일이 어떻게 끝났나 — 실패는 `DioException` 으로 던진다
enum RestrictedStepResult {
/// 마쳤다 — 세션이 그대로다
done,
/// 지금 비밀번호가 없다(로그인 때 넣은 것을 쥐고 있지 않다 — 10분 지남 · 앱을 다시 열었다). 보내지 않았다 — 화면이 칸을 보인다
needsPassword,
/// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않았다
stale,
}
/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기) · 로그아웃 · 세션 끝 · 제한 다시 맞추기.
///
/// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다.
/// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다.
/// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다.
/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다.
/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의
/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게).
/// 비밀이 든 요청(로그인 · 코드 · 비밀번호 바꾸기 · 2단계 켜기)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다.
/// - **시도 세대**([_epoch] — 화면은 [attempt]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 ·
/// 제한 단계 · 제한 다시 맞추기의 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게).
class SessionNotifier extends AsyncNotifier<SessionState> {
LoginSecret? _secretHolder;
Future<RestrictionCheck>? _rechecking;
int _epoch = 0;
bool _passedCodeStep = false;
/// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다
final List<SessionRecheckNeeded> _pendingRechecks = <SessionRecheckNeeded>[];
@@ -59,6 +76,8 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
AuthRepository get _repository => ref.read(authRepositoryProvider);
TwoFactorRepository get _twoFactor => ref.read(twoFactorRepositoryProvider);
TokenStore get _store => ref.read(tokenStoreProvider);
TokenRefresher get _refresher => ref.read(tokenRefresherProvider);
@@ -82,9 +101,18 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
return _restore();
}
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
/// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 제한 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null)
({String email, String password})? get heldLogin => _secret.read();
/// 로그인 때 넣은 비밀번호를 쥐고 있는지 — 제한 단계 화면이 듣고, 없으면 임시 · 지금 비밀번호 칸을 보인다(값은 참 · 거짓뿐)
ValueListenable<bool> get heldLoginListenable => _secret.held;
/// 시도 세대 — 화면이 요청 전에 읽어 두고, 끝난 뒤 다르면(그사이 로그아웃 · 다른 계정 · 세션 끝) 결과를 보이지 않는다
int get attempt => _epoch;
/// 이번 로그인이 코드 단계를 거쳤다 — 2단계 인증을 켠 직원이라 새 비밀번호 단계가 처음부터 코드 칸을 보인다(계약 6절)
bool get passedCodeStep => _passedCodeStep;
/// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다
void retry() {
_refresher.forgetFailure();
@@ -94,6 +122,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// 비밀번호 단계
Future<void> signIn({required String email, required String password}) async {
final int epoch = _epoch;
_passedCodeStep = false;
final AppLoginResult result = await _repository.login(
email: email,
password: password,
@@ -103,12 +132,18 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절).
///
/// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다.
/// - 10분이 지났으면 보내지 않고 첫 단계로 돌아간다(주소는 남긴다).
/// - `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다 — 첫 단계가 위쪽 상자에 보인다(주소는 남긴다).
/// - 코드를 실었는데도 서버가 코드를 묻으면(`twoFactorRequired`) 받아들이지 않은 코드로 보고 `AUTH_006` 으로 던진다(웹과 같다).
Future<void> submitCode(String otpCode) async {
final SessionState? current = state.value;
final ({String email, String password})? held = _secret.read();
if (held == null) {
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.codeTimedOut),
state = AsyncData<SessionState>(
SignedOut(
reason: SignOutReason.codeTimedOut,
email: current is CodeRequired ? current.email : null,
),
);
return;
}
@@ -126,18 +161,150 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
ref.mounted &&
apiErrorCodeOf(error) == 'AUTH_004') {
_secret.clear();
state = const AsyncData<SessionState>(SignedOut());
state = AsyncData<SessionState>(
SignedOut(reason: SignOutReason.passwordRejected, email: held.email),
);
}
rethrow;
}
if (result.status == LoginStatus.twoFactorRequired) {
if (!ref.mounted || epoch != _epoch) return;
throw apiFailure(RequestOptions(path: ApiPaths.appLogin), 'AUTH_006');
}
await _apply(
result,
epoch: epoch,
email: held.email,
password: held.password,
viaCode: true,
);
}
/// 새 비밀번호 단계(제한 `passwordChange` — 계약 6절 "비밀번호 바꾸기 강제") — `PUT /api/auth/password`.
///
/// [currentPassword] 가 null 이면 로그인 때 쥔 비밀번호(임시 비밀번호)를 쓴다 — 쥐고 있지 않으면 보내지 않고 [RestrictedStepResult.needsPassword].
/// [otpCode] 는 2단계 인증을 켠 직원만(로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 다음 코드).
/// - 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다 — 화면이 임시 비밀번호 칸을 그 오류와 함께 보인다.
/// - 바뀌면 새 비밀번호를 쥔다(다음 단계 2단계 켜기의 지금 비밀번호 — 서버가 방금 확인했으니 10분을 새로 센다).
/// 다음 화면으로 옮기는 일은 [finishRestrictedStep] 이 한다.
Future<RestrictedStepResult> changePassword({
required String newPassword,
String? currentPassword,
String? otpCode,
}) async {
final int epoch = _epoch;
final SessionState? current = state.value;
if (current is! SignedIn) return RestrictedStepResult.stale;
final ({String email, String password})? held = currentPassword == null
? _secret.read()
: null;
final String? password = currentPassword ?? held?.password;
if (password == null) return RestrictedStepResult.needsPassword;
try {
await _repository.changePassword(
currentPassword: password,
newPassword: newPassword,
otpCode: otpCode,
);
} on DioException catch (error) {
if (epoch == _epoch &&
ref.mounted &&
held != null &&
apiErrorCodeOf(error) == 'AUTH_005') {
_secret.clear();
}
rethrow;
}
if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale;
_secret.hold(current.info.email, newPassword);
return RestrictedStepResult.done;
}
/// 2단계 켜기 단계(제한 `twoFactorSetup` — 계약 6절 "2단계 인증 필수") — `POST /api/two-factor/activation`.
///
/// [currentPassword] 가 null 이면 쥔 비밀번호(로그인 때 넣은 것 — 방금 새 비밀번호를 정했으면 그것)를 쓴다 — 없으면 [RestrictedStepResult.needsPassword].
/// 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다. 켜면 쥔 비밀번호를 지운다(다시 쓸 일이 없다). 다음 화면은 [finishRestrictedStep].
Future<RestrictedStepResult> enableTwoFactor({
required String enrollmentId,
required String otpCode,
String? currentPassword,
}) async {
final int epoch = _epoch;
if (state.value is! SignedIn) return RestrictedStepResult.stale;
final ({String email, String password})? held = currentPassword == null
? _secret.read()
: null;
final String? password = currentPassword ?? held?.password;
if (password == null) return RestrictedStepResult.needsPassword;
try {
await _twoFactor.activate(
enrollmentId: enrollmentId,
otpCode: otpCode,
currentPassword: password,
);
} on DioException catch (error) {
if (epoch == _epoch &&
ref.mounted &&
held != null &&
apiErrorCodeOf(error) == 'AUTH_005') {
_secret.clear();
}
rethrow;
}
if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale;
_secret.clear();
return RestrictedStepResult.done;
}
/// 제한 단계를 마친 뒤 — 새 토큰을 받아 다음 제한(있으면 그 단계) · 제한 없음(메일)으로 옮긴다
/// (계약 6절 "제한된 세션" — 푸는 요청이 성공하면 곧바로 갱신. 갱신도 한 번에 하나인 [TokenRefresher] 를 지난다).
///
/// - 갱신이 일시 장애면 세션 확인(me — 세션 표의 지금 값)으로 정한다(웹과 같다). 둘 다 받지 못하면 갱신의 실패를 `DioException` 으로 던진다 —
/// 화면은 그 단계에 머물며 위쪽 상자에 사전 문구와 [다시 시도](이것을 다시 부른다).
/// - 갱신이 세션을 끝냈거나(401 · 유예 응답) 그사이 로그아웃했으면 [RestrictedStepResult.stale] — 세션 끝 흐름이 로그인 화면으로 옮긴다.
/// - 방금의 갱신 실패를 다시 쓰지 않는다 — [다시 시도]는 사용자가 누른 것이라 새로 묻는다(되살리기의 [retry] 와 같다).
Future<RestrictedStepResult> finishRestrictedStep() async {
final int epoch = _epoch;
final SessionState? current = state.value;
if (current is! SignedIn) return RestrictedStepResult.stale;
bool isCurrent() =>
epoch == _epoch && ref.mounted && state.value is SignedIn;
_refresher.forgetFailure();
final RefreshOutcome outcome = await _repository.refresh();
if (!isCurrent()) return RestrictedStepResult.stale;
ApiException? refreshError;
switch (outcome) {
case RefreshSucceeded(:final Map<String, Object?>? body):
final AuthSessionInfo? info = AuthSessionInfo.tryParse(body);
if (info != null && info.email == current.info.email) {
_setSignedIn(info);
return RestrictedStepResult.done;
}
// 세션 칸이 없다(그사이 다른 요청이 먼저 받아 두었다) — 아래 세션 확인으로 정한다
case RefreshRejected():
case RefreshDiscarded():
return RestrictedStepResult.stale;
case RefreshFailed(:final ApiException error):
refreshError = error;
}
final AuthSessionInfo info;
try {
info = await _repository.readSessionDirect();
} on DioException {
if (!isCurrent()) return RestrictedStepResult.stale;
if (refreshError == null) rethrow;
throw DioException(
requestOptions: RequestOptions(path: ApiPaths.appRefresh),
error: refreshError,
);
}
if (!isCurrent()) return RestrictedStepResult.stale;
_setSignedIn(info);
return RestrictedStepResult.done;
}
/// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고,
/// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다.
Future<void> switchAccount() async {
@@ -195,6 +362,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
/// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게)
void _advanceEpoch() {
_epoch++;
_passedCodeStep = false;
_rechecking = null;
final List<SessionRecheckNeeded> waiting = List<SessionRecheckNeeded>.of(
_pendingRechecks,
@@ -277,6 +445,7 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
required int epoch,
required String email,
required String password,
bool viaCode = false,
}) async {
if (!ref.mounted || epoch != _epoch) {
// 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다
@@ -311,15 +480,17 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
return;
}
final AuthSessionInfo info = result.session!;
if (info.restriction != null) {
// 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다
_passedCodeStep = viaCode;
if (_usesHeldPassword(info.restriction)) {
// 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다.
// 모르는 제한은 쓸 단계가 없어 쥐지 않는다(웹 LoginPage 의 onSignedIn 과 같다)
_secret.hold(email, password);
}
_setSignedIn(info);
}
}
/// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다
/// 로그인한 상태로 둔다 — 제한이 풀렸거나 모르는 제한이 됐으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다
void _setSignedIn(AuthSessionInfo info) {
final SessionState? previous = state.value;
final bool wasReady =
@@ -329,12 +500,18 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
}
void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) {
// 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다
// (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn)
if (!_usesHeldPassword(info.restriction)) _secret.clear();
if (info.restriction != null) return;
// 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일")
_secret.clear();
if (!wasReady) unawaited(_registerPush());
}
/// 로그인 때 넣은 비밀번호를 지금 비밀번호로 쓰는 제한 단계인가 — 새 비밀번호 · 2단계 켜기만
static bool _usesHeldPassword(SessionRestriction? restriction) =>
restriction == SessionRestriction.passwordChange ||
restriction == SessionRestriction.twoFactorSetup;
Future<void> _registerPush() async {
try {
await _push.register();
@@ -9,17 +9,20 @@ sealed class SessionState {
/// 로그아웃 상태 — 로그인 화면
final class SignedOut extends SessionState {
const SignedOut({this.reason = SignOutReason.none});
const SignedOut({this.reason = SignOutReason.none, this.email});
/// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다)
/// 로그인 화면이 위쪽 상자에 보일 까닭
final SignOutReason reason;
/// 로그인 화면의 주소 칸에 남길 주소 — 코드 단계에서 첫 단계로 돌아왔을 때(시간 지남 · 비밀번호 틀림). 비밀이 아니다
final String? email;
@override
bool operator ==(Object other) =>
other is SignedOut && other.reason == reason;
other is SignedOut && other.reason == reason && other.email == email;
@override
int get hashCode => reason.hashCode;
int get hashCode => Object.hash(reason, email);
}
/// 로그아웃한 까닭
@@ -33,6 +36,9 @@ enum SignOutReason {
/// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.")
codeTimedOut,
/// 코드 단계에서 보낸 비밀번호를 서버가 거절했다(`AUTH_004` — 그사이 바뀜). 위쪽 상자에 사전 문구 · 칸은 빨갛게(계약 5절)
passwordRejected,
/// [다른 계정으로 로그인]
switchAccount,
}
@@ -0,0 +1,395 @@
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/otp_code.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
/// 로그인 틀의 글자 칸 — 칸 이름은 칸 위, 안내 · 오류는 칸 아래(앱 보드 `App1Login` · 웹 PasswordField · OtpCodeField 와 같은 모양).
///
/// - 안내와 오류는 따로 그린다 — 오류가 나도 안내는 남는다(예: 코드 칸의 "다음 코드가 뜨면 넣어 주세요"). [hintIsError] 면 안내 글이 빨갛다.
/// - 화면 낭독기: 보이는 이름은 빼고 같은 이름을 칸에 붙인다(이름을 두 번 읽지 않는다). 칸의 설명은 위쪽 상자([describedBy]) → 안내 → 오류
/// 차례로 칸에 이어 둔다 — 칸에 다시 와도 함께 읽힌다(웹 `aria-describedby` 와 같은 결 · Android 는 칸의 hint 글).
/// 칸 아래 오류는 나타날 때도 읽어 준다(live region). 오류가 있으면 칸이 빨갛고 "잘못됨"(validationResult)이다.
/// - 비밀이 드는 칸(비밀번호 · 코드)의 글은 이 칸의 컨트롤러(화면 메모리)에만 있다 — 화면이 떠날 때 지운다.
class LoginTextField extends StatelessWidget {
const LoginTextField({
required this.label,
required this.controller,
this.focusNode,
this.placeholder,
this.description,
this.hint,
this.hintIsError = false,
this.error,
this.invalid = false,
this.describedBy,
this.obscureText = false,
this.keyboardType,
this.enableSuggestions = false,
this.textInputAction,
this.onSubmitted,
this.onChanged,
this.autofillHints,
this.suffix,
this.readOnly = false,
this.inputFormatters,
this.style,
this.contentPadding,
this.labelStyle,
super.key,
});
final String label;
final TextEditingController controller;
final FocusNode? focusNode;
/// 칸 안의 흐린 글
final String? placeholder;
/// 이름과 칸 사이의 설명 (2단계 켜기의 ② — 보드)
final String? description;
/// 칸 아래 안내
final String? hint;
final bool hintIsError;
/// 칸 아래 오류 — 사전 문구
final String? error;
/// 칸 아래 글 없이 빨간 테두리만 — 오류 글은 위쪽 상자에 있다(첫 단계의 `AUTH_004` — 보드)
final bool invalid;
/// 칸을 설명하는 위쪽 상자의 글 — 칸에 이어 함께 읽힌다(보드의 `aria-describedby="err-login-alert"`)
final String? describedBy;
final bool obscureText;
final TextInputType? keyboardType;
/// 자판의 추천 · 학습 — 기본은 끈다. 메일 주소 칸만 켠다: 끄면 Flutter Android 가 VISIBLE_PASSWORD 변형을 더해
/// EMAIL_ADDRESS 와 겹쳐(FILTER 변형이 된다) 자판에서 @ 가 빠진다(엔진 TextInputPlugin `inputTypeFromTextInputType`)
final bool enableSuggestions;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
/// 칸 끝의 단추([보기])
final Widget? suffix;
/// 보내는 동안 읽기만
final bool readOnly;
final List<TextInputFormatter>? inputFormatters;
/// 칸 글자 — 기본은 테마(bodyLarge 16)
final TextStyle? style;
final EdgeInsetsGeometry? contentPadding;
/// 이름 글자 — 기본은 칸 이름(13 · 굵게)
final TextStyle? labelStyle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final InputDecorationThemeData decorations = InputDecorationTheme.of(
context,
);
final String? hintText = hint;
final String? errorText = error;
final bool red =
invalid || errorText != null || (hintIsError && hintText != null);
final String? describe = description;
final String semanticsHint = <String?>[
describedBy,
hintText,
errorText,
].whereType<String>().join('\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s6,
children: <Widget>[
ExcludeSemantics(
child: KeepAllText(
label,
style:
labelStyle ??
theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textStrong,
),
),
),
if (describe != null)
KeepAllText(
describe,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Semantics(
label: label,
hint: semanticsHint.isEmpty ? null : semanticsHint,
validationResult: red
? SemanticsValidationResult.invalid
: SemanticsValidationResult.none,
child: TextField(
controller: controller,
focusNode: focusNode,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onChanged: onChanged,
autofillHints: autofillHints,
readOnly: readOnly,
inputFormatters: inputFormatters,
style: style,
autocorrect: false,
enableSuggestions: enableSuggestions,
// 안내 · 오류는 입력 장식에 두지 않고 아래에 직접 그린다 — 보드처럼 칸의 왼쪽 끝에 맞추고 낱말 가운데서 줄을 바꾸지 않게
decoration: InputDecoration(
hintText: placeholder,
hintStyle: style?.copyWith(color: theme.colorScheme.outline),
enabledBorder: red ? decorations.errorBorder : null,
focusedBorder: red ? decorations.focusedErrorBorder : null,
suffixIcon: suffix,
contentPadding: contentPadding,
),
),
),
if (hintText != null)
LoginFieldMessage(
text: hintText,
isError: hintIsError,
announce: false,
),
if (errorText != null)
LoginFieldMessage(text: errorText, isError: true),
],
);
}
}
/// 칸 아래 글 — 안내(회색 · 규칙에 어긋나면 빨강) 또는 오류(빨강).
/// [announce] 면 나타날 때 화면 낭독기가 읽는다(live region — 서버 거절 · 보내기 전 검사의 오류 글)
class LoginFieldMessage extends StatelessWidget {
const LoginFieldMessage({
required this.text,
required this.isError,
bool? announce,
super.key,
}) : announce = announce ?? isError;
final String text;
final bool isError;
final bool announce;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final Widget message = KeepAllText(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: isError
? theme.colorScheme.error
: theme.extension<AppExtraColors>()!.textMuted,
),
);
if (!announce) return message;
return Semantics(container: true, liveRegion: true, child: message);
}
}
/// 비밀번호 칸 — 끝에 [보기](48). 보드: 첫 단계 비밀번호 · 새 비밀번호 · 임시 비밀번호 · 지금 비밀번호
class LoginPasswordField extends StatelessWidget {
const LoginPasswordField({
required this.label,
required this.controller,
required this.shown,
required this.onToggle,
required this.toggleLabel,
this.focusNode,
this.placeholder,
this.hint,
this.hintIsError = false,
this.error,
this.invalid = false,
this.describedBy,
this.textInputAction,
this.onSubmitted,
this.onChanged,
this.autofillHints,
this.readOnly = false,
super.key,
});
final String label;
final TextEditingController controller;
/// 글자를 보이는 중
final bool shown;
final VoidCallback onToggle;
/// [보기]의 화면 낭독기 이름 — "비밀번호 보기"
final String toggleLabel;
final FocusNode? focusNode;
final String? placeholder;
final String? hint;
final bool hintIsError;
final String? error;
final bool invalid;
final String? describedBy;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final Iterable<String>? autofillHints;
final bool readOnly;
@override
Widget build(BuildContext context) {
return LoginTextField(
label: label,
controller: controller,
focusNode: focusNode,
placeholder: placeholder,
hint: hint,
hintIsError: hintIsError,
error: error,
invalid: invalid,
describedBy: describedBy,
obscureText: !shown,
// 보이는 동안에도 자판의 추천 · 학습을 끈다
keyboardType: TextInputType.visiblePassword,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onChanged: onChanged,
autofillHints: autofillHints,
readOnly: readOnly,
suffix: PasswordVisibilityToggle(
shown: shown,
label: toggleLabel,
onToggle: onToggle,
),
);
}
}
/// [보기] — 눌러 둔 상태(toggled)를 화면 낭독기가 읽는다(웹의 aria-pressed). 이름은 그대로 둔다
class PasswordVisibilityToggle extends StatelessWidget {
const PasswordVisibilityToggle({
required this.shown,
required this.label,
required this.onToggle,
super.key,
});
final bool shown;
final String label;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
return MergeSemantics(
child: Semantics(
label: label,
toggled: shown,
child: IconButton(
onPressed: onToggle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
icon: Icon(
shown ? Icons.visibility_off_outlined : Icons.visibility_outlined,
size: AppSize.iconMedium,
),
),
),
);
}
}
/// 인증 앱 코드 칸 — 숫자 6자리 · 높이 56 · 큰 글자 사이를 벌린다(보드). 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다.
///
/// 숫자 자판 · 자동 채우기 이름 one-time-code(웹 `inputmode="numeric"` · `autocomplete="one-time-code"`).
class LoginCodeField extends StatelessWidget {
const LoginCodeField({
required this.label,
required this.controller,
this.focusNode,
this.description,
this.hint,
this.error,
this.textInputAction,
this.onSubmitted,
this.onChanged,
this.readOnly = false,
this.labelStyle,
super.key,
});
final String label;
final TextEditingController controller;
final FocusNode? focusNode;
final String? description;
final String? hint;
final String? error;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final bool readOnly;
final TextStyle? labelStyle;
@override
Widget build(BuildContext context) {
final TextTheme text = Theme.of(context).textTheme;
final double fontSize =
text.headlineSmall?.fontSize ?? AppTypography.headline;
final TextStyle style = TextStyle(
fontSize: fontSize,
fontWeight: AppTypography.regular,
height: AppTypography.lineHeightBase,
letterSpacing: fontSize * AppTypography.letterSpacingCodeEm,
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
);
final double lineHeight = fontSize * AppTypography.lineHeightBase;
return LoginTextField(
label: label,
controller: controller,
focusNode: focusNode,
placeholder: OtpTexts.placeholder,
description: description,
hint: hint,
error: error,
keyboardType: TextInputType.number,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onChanged: onChanged,
autofillHints: const <String>[AutofillHints.oneTimeCode],
readOnly: readOnly,
inputFormatters: <TextInputFormatter>[OtpInputFormatter()],
style: style,
labelStyle: labelStyle,
contentPadding: EdgeInsets.symmetric(
horizontal: AppSpacing.s16,
vertical: (AppSize.codeFieldHeight - lineHeight) / 2,
),
);
}
}
/// 코드 칸의 글을 숫자 6자리로 다듬는다 — 붙여 넣은 "123 456" · "123-456" · 전각 숫자도 받는다(`normalizeOtpInput`)
class OtpInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final String digits = normalizeOtpInput(newValue.text);
if (digits == newValue.text) return newValue;
return TextEditingValue(
text: digits,
selection: TextSelection.collapsed(offset: digits.length),
);
}
}
@@ -0,0 +1,376 @@
import 'package:arasystem_app/core/constants/app_assets.dart';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/constants/login_texts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 로그인 틀의 크기 — 앱 보드 `App1Login` (2026-11-05 확정). 여러 화면이 함께 쓰는 크기는 `AppSize` · `AppSpacing` 에 있다
abstract final class LoginLayout {
/// 첫 단계 브랜드 띠의 전체 로고 폭 (높이는 그림 비율대로)
static const double logoWidth = 150;
/// 다음 단계 짙은 띠의 ARA 마크 폭
static const double markWidth = 48;
/// 2단계 켜기의 차례 숫자(1 · 2 · 3) 칸 폭
static const double stepNumberWidth = 18;
/// 보내는 동안 단추를 흐리게 — 웹 로그인 단추와 같다
static const double busyOpacity = 0.7;
/// 보내는 동안 [다른 계정으로 로그인]을 흐리게 — 웹 LoginAccountRow 와 같다
static const double busyLinkOpacity = 0.6;
}
/// 로그인 틀의 상태 표시줄 — 위가 어두운 브랜드 띠라 밝은 아이콘. 탐색 막대는 건드리지 않는다(AppBar 와 같다)
const SystemUiOverlayStyle kLoginOverlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemStatusBarContrastEnforced: false,
);
/// 로그인 틀의 칸 · 단추 모양 — 보드는 모서리 10 · 글자 칸 52 · 단추 48 이다(앱 전체 테마의 8 과 다르다)
ThemeData loginThemeOf(ThemeData base) {
final ColorScheme scheme = base.colorScheme;
final TextTheme text = base.textTheme;
final BorderRadius radius = BorderRadius.circular(AppRadius.lg);
final RoundedRectangleBorder shape = RoundedRectangleBorder(
borderRadius: radius,
);
OutlineInputBorder border(Color color, double width) => OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide(color: color, width: width),
);
final TextStyle input = text.bodyLarge ?? const TextStyle();
final double lineHeight =
(input.fontSize ?? AppTypography.bodyLarge) *
(input.height ?? AppTypography.lineHeightBase);
const Size buttonSize = Size.fromHeight(AppSize.touchMin);
return base.copyWith(
inputDecorationTheme: InputDecorationThemeData(
filled: true,
fillColor: scheme.surface,
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: AppSpacing.s14,
vertical: (AppSize.fieldHeight - lineHeight) / 2,
),
hintStyle: input.copyWith(color: scheme.outline),
// 칸 아래 안내 · 오류는 칸이 직접 그린다(LoginFieldMessage) — 오류 테두리만 여기서 정한다
border: border(scheme.outline, AppSize.borderWidth),
enabledBorder: border(scheme.outline, AppSize.borderWidth),
focusedBorder: border(scheme.primary, AppSize.focusRingWidth),
errorBorder: border(scheme.error, AppSize.focusRingWidth),
focusedErrorBorder: border(scheme.error, AppSize.focusRingWidth),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: buttonSize,
shape: shape,
textStyle: text.labelLarge,
// 보내는 동안(누를 수 없음)에도 같은 색을 흐리게 — 회색 단추로 바뀌지 않게
disabledBackgroundColor: scheme.primary.withValues(
alpha: LoginLayout.busyOpacity,
),
disabledForegroundColor: scheme.onPrimary,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: buttonSize,
shape: shape,
side: BorderSide(color: scheme.primary),
foregroundColor: scheme.onPrimaryContainer,
textStyle: text.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(AppSize.touchMin, AppSize.touchMin),
shape: shape,
textStyle: text.labelLarge,
),
),
);
}
/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다
class LoginBrandFrame extends StatelessWidget {
const LoginBrandFrame({required this.children, super.key});
/// 브랜드 띠 아래의 내용 — 사이 20
final List<Widget> children;
@override
Widget build(BuildContext context) {
return _LoginScaffold(
top: const _StatusBarStrip(),
body: _LoginScroll(
children: <Widget>[
const _BrandBand(),
_LoginColumn(topPadding: AppSpacing.s28, children: children),
],
),
);
}
}
/// 다음 단계들의 틀 — 짙은 띠(56 — ARA 마크 · [다른 계정으로 로그인])는 위에 남고 내용만 흐른다
class LoginStepFrame extends StatelessWidget {
const LoginStepFrame({
required this.onOtherAccount,
required this.children,
this.otherAccountEnabled = true,
this.topPadding = AppSpacing.s28,
super.key,
});
/// [다른 계정으로 로그인]
final VoidCallback onOtherAccount;
/// [다른 계정으로 로그인]을 누를 수 있는가 — 제한 단계는 보내는 동안 · 넘기는 동안 막는다(웹 LoginAccountRow 의 busy 와 같다).
/// 서버가 이미 비밀번호를 바꿨거나 2단계를 켰을 수 있는데 그 결과를 버리면, 직원이 옛 비밀번호로 다시 들어가다 막힌다
final bool otherAccountEnabled;
/// 띠 아래의 내용 — 사이 20
final List<Widget> children;
/// 띠와 첫 내용 사이 (보드: 코드 · 새 비밀번호 28, 2단계 켜기 24)
final double topPadding;
@override
Widget build(BuildContext context) {
return _LoginScaffold(
top: _StepBand(
onOtherAccount: otherAccountEnabled ? onOtherAccount : null,
),
body: _LoginScroll(
children: <Widget>[
_LoginColumn(topPadding: topPadding, children: children),
],
),
);
}
}
class _LoginScaffold extends StatelessWidget {
const _LoginScaffold({required this.top, required this.body});
final Widget top;
final Widget body;
@override
Widget build(BuildContext context) {
final ThemeData theme = loginThemeOf(Theme.of(context));
return AnnotatedRegion<SystemUiOverlayStyle>(
value: kLoginOverlayStyle,
child: Theme(
data: theme,
child: Scaffold(
backgroundColor: theme.colorScheme.surface,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
top,
Expanded(child: body),
],
),
),
),
);
}
}
/// 상태 표시줄 뒤 — 브랜드 바탕(내용이 흘러 올라가도 밝은 아이콘이 보이게)
class _StatusBarStrip extends StatelessWidget {
const _StatusBarStrip();
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Theme.of(context).colorScheme.secondary,
child: SizedBox(height: MediaQuery.paddingOf(context).top),
);
}
}
class _LoginScroll extends StatelessWidget {
const _LoginScroll({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
);
}
}
/// 내용 열 — 휴대폰은 화면 폭을 다 쓰고, 넓은 화면은 [AppSize.formMaxWidth] 로 가운데
class _LoginColumn extends StatelessWidget {
const _LoginColumn({required this.topPadding, required this.children});
final double topPadding;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return _FormWidth(
child: Padding(
padding: EdgeInsets.fromLTRB(
AppSpacing.s20,
topPadding,
AppSpacing.s20,
AppSpacing.s32,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s20,
children: children,
),
),
);
}
}
class _FormWidth extends StatelessWidget {
const _FormWidth({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: AppSize.formMaxWidth),
child: child,
),
);
}
}
/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글
class _BrandBand extends StatelessWidget {
const _BrandBand();
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return ColoredBox(
color: theme.colorScheme.secondary,
child: _FormWidth(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s24,
AppSpacing.s36,
AppSpacing.s24,
AppSpacing.s28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s18,
children: <Widget>[
Image.asset(
AppAssets.logoFull,
width: LoginLayout.logoWidth,
semanticLabel: kAppName,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s6,
children: <Widget>[
KeepAllText(
LoginTexts.brandHeadline,
// 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand)
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
),
),
KeepAllText(
LoginTexts.brandLead,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.onBrandMuted,
height: AppTypography.lineHeightRelaxed,
),
),
],
),
],
),
),
),
);
}
}
/// 다음 단계의 짙은 띠 — ARA 마크와 [다른 계정으로 로그인] (웹처럼 주소 줄 안에 두면 390 폭에 들지 않는다)
class _StepBand extends StatelessWidget {
const _StepBand({required this.onOtherAccount});
/// null 이면 누를 수 없다(흐리게)
final VoidCallback? onOtherAccount;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return ColoredBox(
color: theme.colorScheme.secondary,
child: Padding(
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
child: SafeArea(
top: false,
bottom: false,
child: SizedBox(
height: AppSize.topBandHeight,
child: Padding(
padding: const EdgeInsets.only(
left: AppSpacing.s20,
right: AppSpacing.s6,
),
child: Row(
children: <Widget>[
Image.asset(
AppAssets.logoMark,
width: LoginLayout.markWidth,
semanticLabel: kAppName,
),
const Spacer(),
TextButton(
onPressed: onOtherAccount,
style: TextButton.styleFrom(
foregroundColor: extra.onBrandLink,
disabledForegroundColor: extra.onBrandLink.withValues(
alpha: LoginLayout.busyLinkOpacity,
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
),
child: const Text(LoginBandTexts.otherAccount),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,317 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
/// 단계의 머리 — 제목 · 설명 (다음 단계는 로그인한 주소 줄도).
///
/// 제목은 화면 낭독기의 머리글이자 **화면 이름**이다(`namesRoute`) — 단계가 바뀌어 화면이 바뀌면 낭독기가 이 글을 읽어 알린다.
class LoginHeading extends StatelessWidget {
const LoginHeading({
required this.title,
required this.subtitle,
this.email,
super.key,
});
final String title;
final String subtitle;
/// 로그인한(비밀번호를 확인한) 주소 — 다음 단계만
final String? email;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final String? address = email;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: address == null ? AppSpacing.s4 : AppSpacing.s10,
children: <Widget>[
Semantics(
header: true,
namesRoute: true,
child: KeepAllText(title, style: theme.textTheme.headlineSmall),
),
KeepAllText(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (address != null) LoginAccountChip(email: address),
],
);
}
}
/// 로그인한 주소 — 사람 아이콘 + 주소(넘치면 줄임)의 알약(36)
class LoginAccountChip extends StatelessWidget {
const LoginAccountChip({required this.email, super.key});
final String email;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Container(
constraints: const BoxConstraints(minHeight: AppSize.chipHeight),
padding: const EdgeInsetsDirectional.only(
start: AppSpacing.s10,
end: AppSpacing.s14,
),
decoration: ShapeDecoration(
color: theme.colorScheme.surfaceContainerHigh,
shape: const StadiumBorder(),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.s8,
children: <Widget>[
Icon(
Icons.person_outline,
size: AppSize.iconSmall,
color: extra.textMuted,
),
Flexible(
child: Text(
email,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textStrong,
),
),
),
],
),
),
);
}
}
/// 위쪽 빨간 상자 — 사전 문구(`AUTH_004` 등)나 단계 안내. 나타나면 화면 낭독기가 읽는다(live region — 웹의 role="alert").
/// [actionLabel] 이 있으면 글 아래에 단추([다시 시도]) — [onAction] 이 null 이면(다시 하는 중) 자리는 두고 누를 수 없다.
class LoginAlertBox extends StatelessWidget {
const LoginAlertBox({
required this.message,
this.actionLabel,
this.onAction,
this.namesRoute = false,
super.key,
});
final String message;
final String? actionLabel;
final VoidCallback? onAction;
/// 이 상자가 화면 이름이다(제목 없는 화면 — 모르는 제한)
final bool namesRoute;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final String? label = actionLabel;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s14,
vertical: AppSpacing.s12,
),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: AppSpacing.s8,
children: <Widget>[
Semantics(
container: true,
liveRegion: true,
namesRoute: namesRoute,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s8,
children: <Widget>[
Icon(
Icons.error_outline,
size: AppSize.iconSmall,
color: scheme.error,
),
Expanded(
child: KeepAllText(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.error,
),
),
),
],
),
),
if (label != null)
Align(
alignment: AlignmentDirectional.centerEnd,
child: OutlinedButton(
onPressed: onAction,
style: OutlinedButton.styleFrom(
minimumSize: const Size(AppSize.touchMin, AppSize.touchMin),
side: BorderSide(color: scheme.outlineVariant),
backgroundColor: scheme.surface,
foregroundColor: theme
.extension<AppExtraColors>()!
.textStrong,
),
child: Text(label),
),
),
],
),
);
}
}
/// 단계의 큰 단추 — 보내는 동안은 [busyLabel] 로 바꾸고 누를 수 없다(두 번 보내면 서버가 `REQ_001` — 계약 1절)
class LoginSubmitButton extends StatelessWidget {
const LoginSubmitButton({
required this.label,
required this.busyLabel,
required this.busy,
required this.onPressed,
this.enabled = true,
this.inForm = false,
super.key,
});
final String label;
final String busyLabel;
final bool busy;
final VoidCallback onPressed;
/// 보내는 중이 아니어도 누를 수 없음(키를 받는 중 · 이미 마쳤는데 넘기지 못함)
final bool enabled;
/// 칸 묶음(사이 16) 안의 단추 — 위를 4 더 띄워 칸과 20 을 둔다(보드)
final bool inForm;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: inForm ? AppSpacing.s4 : 0),
child: FilledButton(
onPressed: busy || !enabled ? null : onPressed,
child: Text(busy ? busyLabel : label),
),
);
}
}
/// 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처(진하게), 없거나 받지 못했으면 지금 글(웹과 같다). 글자로만 보인다
class LoginSupportLine extends StatelessWidget {
const LoginSupportLine({
required this.question,
required this.fallback,
required this.contact,
super.key,
});
final String question;
final String fallback;
/// 연락처 한 줄 — 없으면 null
final String? contact;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? style = theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
);
final String? line = contact;
if (line == null) return KeepAllText(fallback, style: style);
return Text.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(text: '${keepAllWordBreak(question)}\n'),
TextSpan(
text: keepAllWordBreak(line),
style: TextStyle(
color: extra.textStrong,
fontWeight: AppTypography.medium,
),
),
],
),
style: style,
semanticsLabel: '$question\n$line',
);
}
}
/// 맨 아래 안내 — 위쪽 옅은 선 · 작은 회색 글(메일 프로그램 연결 안내)
class LoginNote extends StatelessWidget {
const LoginNote({required this.text, super.key});
final String text;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return DecoratedBox(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: extra.borderSubtle)),
),
child: Padding(
padding: const EdgeInsets.only(top: AppSpacing.s16),
child: KeepAllText(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
height: AppTypography.lineHeightRelaxed,
),
),
),
);
}
}
/// 2단계 켜기의 차례 하나 — 청록 굵은 숫자(동그라미 없음) + 내용 (숫자는 화면 낭독기가 읽지 않는다 — 보드의 aria-hidden)
class LoginStepItem extends StatelessWidget {
const LoginStepItem({required this.number, required this.child, super.key});
final int number;
final Widget child;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s12,
children: <Widget>[
SizedBox(
width: LoginLayout.stepNumberWidth,
child: ExcludeSemantics(
child: Text(
'$number',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: AppTypography.bold,
color: theme.colorScheme.primary,
),
),
),
),
Expanded(child: child),
],
);
}
}