feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭

앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다.

- firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다.
  google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다).
- 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다.
- 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다
  (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다).
- 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다.
- 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다).
- 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다.
- 시험 932 → 1,056.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:46:11 +09:00
parent bf725ae96d
commit e03eda1fd3
74 changed files with 7067 additions and 229 deletions
@@ -0,0 +1,52 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다
typedef StoreLaunch = Future<bool> Function(Uri url, LaunchMode mode);
Future<bool> _launchExternal(Uri url, LaunchMode mode) =>
launchUrl(url, mode: mode);
/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소)
const String kPlayStoreWebDetails =
'https://play.google.com/store/apps/details';
/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면).
///
/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로.
/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 —
/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다
class AppStoreLauncher {
const AppStoreLauncher({StoreLaunch launch = _launchExternal})
: _launch = launch;
final StoreLaunch _launch;
/// 열었으면 참
Future<bool> open(String packageName) async {
final Uri market = Uri(
scheme: 'market',
host: 'details',
queryParameters: <String, String>{'id': packageName},
);
if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) {
return true;
}
final Uri web = Uri.parse(
kPlayStoreWebDetails,
).replace(queryParameters: <String, String>{'id': packageName});
return _tryLaunch(web, LaunchMode.externalApplication);
}
Future<bool> _tryLaunch(Uri url, LaunchMode mode) async {
try {
return await _launch(url, mode);
} on Object {
return false;
}
}
}
/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다
final Provider<AppStoreLauncher> appStoreLauncherProvider =
Provider<AppStoreLauncher>((Ref ref) => const AppStoreLauncher());
@@ -0,0 +1,22 @@
/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`.
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class AppUpdateTexts {
static const String message =
'지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.';
static const String openStore = 'Play 스토어 열기';
/// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0"
static String currentVersion(String version) => '지금 쓰는 버전 $version';
/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처
static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?';
/// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴)
static const String supportFallback =
'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.';
/// Play 스토어를 열지 못했을 때(알림)
static const String openStoreFailed =
'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.';
}
@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart';
import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart';
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다
/// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`).
///
/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄.
/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다.
/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고,
/// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다.
class AppUpdatePage extends ConsumerStatefulWidget {
const AppUpdatePage({super.key});
@override
ConsumerState<AppUpdatePage> createState() => _AppUpdatePageState();
}
class _AppUpdatePageState extends ConsumerState<AppUpdatePage> {
@override
void initState() {
super.initState();
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
}
Future<void> _openStore() async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
final String? packageName = ref.read(appPackageNameProvider);
final bool opened =
packageName != null &&
await ref.read(appStoreLauncherProvider).open(packageName);
if (opened) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)),
);
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? version = ref.watch(appVersionProvider);
return LoginBrandFrame(
introduction: false,
contentTopPadding: AppSpacing.s32,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s10,
children: <Widget>[
Semantics(
header: true,
child: Text(
ScreenTitles.update,
style: theme.textTheme.headlineSmall,
),
),
KeepAllText(
AppUpdateTexts.message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: AppTypography.lineHeightRelaxed,
),
),
],
),
FilledButton(
onPressed: () => unawaited(_openStore()),
child: const Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.s8,
children: <Widget>[
Text(AppUpdateTexts.openStore),
Icon(Icons.open_in_new, size: AppSize.iconSmall),
],
),
),
if (version != null)
Text(
AppUpdateTexts.currentVersion(version),
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
),
),
DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
),
child: Padding(
padding: const EdgeInsets.only(top: AppSpacing.s16),
child: LoginSupportLine(
question: AppUpdateTexts.supportQuestion,
fallback: AppUpdateTexts.supportFallback,
contact: ref.watch(supportContactProvider),
),
),
),
],
);
}
}
@@ -319,8 +319,8 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
);
}
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다.
/// 서버에 닿지 못해도 이 기기는 로그아웃 상태다.
/// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지운다. 그 뒤 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)와 서버 logout 을
/// 함께 보낸다(서로 기다리지 않는다). 서버에 닿지 못해도 이 기기는 로그아웃 상태다. 알림 서버의 기기 줄은 부르지 않는다 — 서버 logout 이 세션과 함께 지운다.
Future<void> signOut({SignOutReason reason = SignOutReason.none}) async {
_advanceEpoch();
_secret.clear();
@@ -424,6 +424,8 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
final String? code = apiErrorCodeOf(error);
if (code == 'AUTH_001' || code == 'AUTH_002') {
await _clearQuietly();
// 앱이 꺼진 사이 세션이 끝났다 — 이 기기의 알림도 멈춘다(세션 끝과 같다)
unawaited(_unregisterPushQuietly());
return const SignedOut();
}
// 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다
@@ -503,7 +505,11 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
// 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다
// (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn)
if (!_usesHeldPassword(info.restriction)) _secret.clear();
if (info.restriction != null) return;
if (info.restriction != null) {
// 쓰던 세션에 제한이 새로 생겼다(갱신 응답 — 비밀번호 만료 · 2단계 기한) — 풀릴 때까지 알림 서버를 부르지 않는다
if (wasReady) _push.pause();
return;
}
if (!wasReady) unawaited(_registerPush());
}
@@ -520,11 +526,13 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
}
}
/// 이 기기의 새 메일 알림을 멈춘다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다(짧은 한도 · 실패는 삼킨다).
/// 서버의 기기 줄은 logout · 세션 끝이 세션과 함께 지운다
Future<void> _unregisterPushQuietly() async {
try {
await _push.unregister().timeout(kPushUnregisterTimeout);
} on Object {
// 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다
// 못 푼 등록은 알림 컨트롤러가 기억해 다음 실행에 다시 푼다
}
}
@@ -556,6 +564,8 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
state = const AsyncData<SessionState>(
SignedOut(reason: SignOutReason.expired),
);
// 이 기기의 알림을 멈춘다 — 서버 줄은 세션을 끝낸 서버가 지운다
unawaited(_unregisterPushQuietly());
case SessionRefreshed(:final Map<String, Object?> body):
// 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다)
if (current is! SignedIn) return;
@@ -100,21 +100,33 @@ ThemeData loginThemeOf(ThemeData base) {
);
}
/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다
/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다.
/// 업데이트 화면(앱 보드 `App1Push` ⑩)도 이 틀을 쓴다 — 띠에 로고만([introduction] 거짓) · 내용 위 32
class LoginBrandFrame extends StatelessWidget {
const LoginBrandFrame({required this.children, super.key});
const LoginBrandFrame({
required this.children,
this.introduction = true,
this.contentTopPadding = AppSpacing.s28,
super.key,
});
/// 브랜드 띠 아래의 내용 — 사이 20
final List<Widget> children;
/// 띠에 큰 글 · 소개 글을 보이는가 — 거짓이면 로고만
final bool introduction;
/// 띠 아래 내용의 위 여백
final double contentTopPadding;
@override
Widget build(BuildContext context) {
return _LoginScaffold(
top: const _StatusBarStrip(),
body: _LoginScroll(
children: <Widget>[
const _BrandBand(),
_LoginColumn(topPadding: AppSpacing.s28, children: children),
_BrandBand(introduction: introduction),
_LoginColumn(topPadding: contentTopPadding, children: children),
],
),
);
@@ -264,9 +276,11 @@ class _FormWidth extends StatelessWidget {
}
}
/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글
/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글([introduction] 이 거짓이면 로고만)
class _BrandBand extends StatelessWidget {
const _BrandBand();
const _BrandBand({required this.introduction});
final bool introduction;
@override
Widget build(BuildContext context) {
@@ -291,26 +305,27 @@ class _BrandBand extends StatelessWidget {
width: LoginLayout.logoWidth,
semanticLabel: kAppName,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s6,
children: <Widget>[
KeepAllText(
LoginTexts.brandHeadline,
// 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand)
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
if (introduction)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s6,
children: <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,
KeepAllText(
LoginTexts.brandLead,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.onBrandMuted,
height: AppTypography.lineHeightRelaxed,
),
),
),
],
),
],
),
],
),
),
@@ -22,6 +22,7 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_state
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -34,6 +35,7 @@ import 'package:go_router/go_router.dart';
/// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다.
/// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인).
/// - 화면이 보이는지(다른 탭 · 전체 화면 · 위에 쌓인 메일함에 가렸는지)는 TickerMode 로 안다.
/// - 받은편지함은 위 막대 아래에 새 메일 알림 권하기 줄을 둔다(보일 때만 — [PushOfferLine]). 고르는 동안은 숨긴다.
class MailListPage extends ConsumerStatefulWidget {
const MailListPage({this.mailboxId, super.key});
@@ -197,22 +199,33 @@ class _MailListPageState extends ConsumerState<MailListPage> {
hidden: selecting,
child: Scaffold(
appBar: appBar,
body: _MailListBody(
list: list,
role: role,
title: title,
mailboxesReady: mailboxesReady,
mailboxesFailure: mailboxes.failure,
unknownMailbox: unknownMailbox,
onRefresh: _controller.refresh,
onRetry: () => unawaited(_controller.retry()),
onRetryMailboxes: () =>
unawaited(ref.read(mailboxesProvider.notifier).reload()),
onLeave: inboxRoute ? null : () => context.go(MailRoutes.home),
onLoadMore: () => unawaited(_controller.loadMore()),
onRowTap: (MessageSummary message) =>
selecting ? _toggle(message) : _open(message, role),
onRowSelect: _toggle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (role == MailboxRole.inbox && !selecting)
const PushOfferLine(),
Expanded(
child: _MailListBody(
list: list,
role: role,
title: title,
mailboxesReady: mailboxesReady,
mailboxesFailure: mailboxes.failure,
unknownMailbox: unknownMailbox,
onRefresh: _controller.refresh,
onRetry: () => unawaited(_controller.retry()),
onRetryMailboxes: () =>
unawaited(ref.read(mailboxesProvider.notifier).reload()),
onLeave: inboxRoute
? null
: () => context.go(MailRoutes.home),
onLoadMore: () => unawaited(_controller.loadMore()),
onRowTap: (MessageSummary message) =>
selecting ? _toggle(message) : _open(message, role),
onRowSelect: _toggle,
),
),
],
),
floatingActionButton: MailComposeArea(
key: const ValueKey<String>('mail-compose-area'),
@@ -47,6 +47,8 @@ const double _bodyLineHeight = 1.7;
/// 올리는 중인 첨부가 있으면 먼저 따로 묻는다("올리는 중인 첨부가 있습니다") — [나가기]면 올리던 파일을 빼고 위 차례대로,
/// 묻는 사이 모두 다 올렸으면 그대로 떠난다(올리지 못한 파일이 생겼으면 답을 기다린다).
/// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다.
/// - 스스로 닫지 않고 내려가면(알림을 눌러 받은편지함으로 · 업데이트 화면으로) 바꾼 것을 끝까지 저장하고 알린다("임시보관함에 저장했습니다."
/// · 못 했으면 그 코드의 사전 문구). 세션이 끝나 내려간 것은 저장하지 않는다.
class MessageComposePage extends ConsumerStatefulWidget {
const MessageComposePage({required this.intent, this.prefill, super.key});
@@ -91,6 +93,9 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
bool _closing = false;
bool _announceSaved = false;
/// 바깥 이동(알림을 눌러 받은편지함으로 · 업데이트 화면)이 잃을 것이 있는 이 화면을 내렸다 — 내려가며 마저 저장한다(detach)
bool _closedOutside = false;
/// 나가기 확인 창이 열려 있다 — 그사이 저장이 끝나면 창을 닫고 떠난다
bool _askingLeave = false;
bool _savedWhileAsking = false;
@@ -122,7 +127,8 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
@override
void dispose() {
_lifecycle.dispose();
_controller.detach();
// 스스로 닫지 않고 내려갔다(알림을 눌러 받은편지함으로 · 업데이트 화면으로) — 바꾼 것을 끝까지 저장한다
_controller.detach(saveRemaining: _closedOutside || !_closing);
for (final FocusNode node in _recipientFocus.values) {
node.dispose();
}
@@ -283,8 +289,19 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
context.go(MailRoutes.home);
}
/// 화면이 닫혔다(뒤로 · 몸짓 · [_close]) — 이 화면이 저장해 둔 것이 있으면 알린다
/// 화면이 닫혔다(뒤로 · 몸짓 · [_close] · 바깥 이동) — 이 화면이 저장해 둔 것이 있으면 알린다.
///
/// 잃을 것이 있으면 뒤로 · 몸짓은 막혀 있다(PopScope 의 canPop) — 그런데도 스스로 닫지 않은 채 닫혔으면 바깥 이동(go — 알림 누르기 ·
/// 업데이트 화면)이 이 화면을 내린 것이다. 그때는 알리지 않고, 내려가며 마저 저장한 뒤 컨트롤러가 알린다(detach)
void _onClosed() {
if (!_closing) {
final ComposeState state = ref.read(composeProvider(_args));
if (state.unsaved && !state.sending && !state.ended) {
_closedOutside = true;
_closing = true;
return;
}
}
final bool announce = _closing ? _announceSaved : _controller.savedForLeave;
_closing = true;
if (announce) _notice(MailComposeTexts.savedOnLeave);
@@ -1546,6 +1546,7 @@ class ComposeController extends Notifier<ComposeState> {
}
void _onSaveFailed(String code, DraftSaveTrigger trigger) {
_lastSaveFailure = code;
if (!ref.mounted || _ended) return;
switch (code) {
case 'MAIL_004':
@@ -1592,13 +1593,40 @@ class ComposeController extends Notifier<ComposeState> {
bool get savedForLeave => _savedOnce && !_sent && !state.sending && !_ended;
/// 화면이 사라진다 — 더 저장하지 않는다(진행 중인 저장 · 보내기는 끝까지 받는다). 저장으로 지운 옛 저장본은 목록에서 뺀다.
/// 위젯을 정리하는 중에 불리므로 상태는 바꾸지 않는다
void detach() {
/// 위젯을 정리하는 중에 불리므로 상태는 바꾸지 않는다.
///
/// [saveRemaining] — 화면이 스스로 닫히지 않고 바깥 이동으로 내려갔다(알림을 눌러 받은편지함으로 · 업데이트 화면으로).
/// 바꾼 것이 남았고 보내지 않았고 세션이 그대로면 끝까지 저장한다([_saveOnDetach] — 앱 README 「쓰기」 "푸시를 붙일 때").
/// 세션이 끝나 내려간 것은 저장하지 않는다(다른 직원의 임시보관함에 남기지 않게).
void detach({bool saveRemaining = false}) {
if (!_attached) return;
_attached = false;
_cancelSearch();
// 올리던 첨부는 그만두고 복사본을 지운다 — 떠나기 전 저장은 다 올린 첨부만 실었다(보내기는 올리는 동안 막혀 있다)
_uploads.close();
if (saveRemaining && _keepsOnDetach) {
// 저장을 기다리기 전에 붙잡는다 — 화면이 사라진 뒤에도 저장이 끝날 때까지 산다(보내기와 같은 규칙)
final KeepAliveLink keepAlive = ref.keepAlive();
final void Function(String text) notice = ref.read(
mailNoticeSinkProvider,
);
final ErrorNotifier errors = ref.read(errorNotifierProvider);
final MailStateWatcher watcher = ref.read(
mailStateWatcherProvider.notifier,
);
// 위젯을 정리하는 중이다 — 상태는 다음 차례에 바꾼다
scheduleMicrotask(
() => unawaited(
_saveOnDetach(
keepAlive: keepAlive,
notice: notice,
errors: errors,
watcher: watcher,
),
),
);
return;
}
if (!_sent && !_sendingNow) _autosave.stop();
final Set<String> removed = <String>{..._replacedIds};
if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) {
@@ -1611,6 +1639,57 @@ class ComposeController extends Notifier<ComposeState> {
bool _sendingNow = false;
/// 마지막 저장 실패의 코드 — 바깥 이동으로 내려간 뒤의 저장이 실패하면 그 코드의 사전 문구로 알린다
String? _lastSaveFailure;
/// 바깥 이동으로 내려갈 때 마저 저장할까 — 보내지 않았고 · 보내는 중이 아니고 · 세션이 그대로고 · 채운 양식이다
bool get _keepsOnDetach =>
ref.mounted &&
!_sent &&
!_sendingNow &&
_sameSession() &&
state.status == ComposeStatus.ready;
/// 바깥 이동으로 내려간 쓰기 화면의 마지막 저장 — 쓰다 만 주소까지 칩으로 만들어 저장하고, 저장했으면(또는 이 화면이 저장한 적이 있으면)
/// "임시보관함에 저장했습니다.", 못 했으면 그 코드의 사전 문구를 전역 알림으로. 세션이 바뀌면 알리지 않는다
Future<void> _saveOnDetach({
required KeepAliveLink keepAlive,
required void Function(String text) notice,
required ErrorNotifier errors,
required MailStateWatcher watcher,
}) async {
try {
if (!ref.mounted || _ended) return;
// 올리던 첨부는 이미 그만두었다(detach) — 다 올린 첨부만 싣는다(보내기 · 나가기의 저장과 같다)
dropUploadingAttachments();
commitAllRecipientInputs();
_autosave.suspend();
_lastSaveFailure = null;
final bool changed = hasUnsavedChanges;
final bool saved = changed ? await _autosave.saveBeforeLeave() : true;
if (!ref.mounted) return;
_autosave.stop();
if (!_sameSession()) return;
final Set<String> removed = <String>{..._replacedIds};
if (removed.isNotEmpty || _savedOnce) {
watcher.markChanged(removedIds: removed);
}
if (saved && !hasUnsavedChanges) {
if (_savedOnce) notice(MailComposeTexts.savedOnLeave);
return;
}
// 저장하지 못했다 — 찾지 못한 첨부 · 대용량 파일은 그 코드로, 그 밖은 저장이 실패한 코드로
final String code = _hasMissingAttachments
? 'MAIL_004'
: _hasMissingLargeFiles
? 'MAIL_016'
: _lastSaveFailure ?? kFallbackCode;
errors.show(ApiException.fromCode(code));
} finally {
keepAlive.close();
}
}
// ── 보내기 ─────────────────────────────────────────
/// 보낸다 — 위 머리말의 차례. [router] 는 보낸 뒤 알림의 단추(보내기 취소 · 보낸편지함 보기)가 쓴다.
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
@@ -71,13 +72,17 @@ final NotifierProvider<MailStateWatcher, MailChange> mailStateWatcherProvider =
/// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다.
/// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다.
/// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다).
/// - 푸시(새 메일 알림)는 다음 차례다.
/// - 새 메일 알림 소식(앱이 앞에 있을 때 온 알림 · 누른 알림 — `core/push/push_events.dart`)이 오면 목록이 보일 때 10초 간격을 건너뛰고
/// 바로 묻는다(묻는 중이면 끝난 뒤 한 번). 안 보이면 보일 때 위 규칙대로다.
class MailStateWatcher extends Notifier<MailChange> {
final Set<Object> _viewers = <Object>{};
Timer? _timer;
bool _foreground = true;
bool _signedIn = false;
bool _asking = false;
/// 묻는 중에 새 메일 알림이 왔다 — 끝난 뒤 한 번 더 묻는다
bool _askAgain = false;
DateTime? _lastAskedAt;
String? _lastState;
@@ -90,14 +95,22 @@ class MailStateWatcher extends Notifier<MailChange> {
_timer?.cancel();
_timer = null;
_asking = false;
_askAgain = false;
_lastAskedAt = null;
_lastState = null;
_foreground = ref.read(appForegroundProvider);
ref.listen<bool>(appForegroundProvider, (bool? previous, bool next) {
final bool before = _active;
_foreground = next;
if (before != _active) _onActiveChanged();
});
ref
..listen<bool>(appForegroundProvider, (bool? previous, bool next) {
final bool before = _active;
_foreground = next;
if (before != _active) _onActiveChanged();
})
..listen<PushEvent?>(pushEventsProvider, (
PushEvent? previous,
PushEvent? next,
) {
if (next != null && next.message.isNewMail) _onNewMailPush();
});
ref.onDispose(() {
_timer?.cancel();
_timer = null;
@@ -143,6 +156,16 @@ class MailStateWatcher extends Notifier<MailChange> {
);
}
/// 새 메일 알림이 왔다 — 보이는 목록이 있으면 바로 묻는다(묻는 중이면 끝난 뒤 한 번)
void _onNewMailPush() {
if (!ref.mounted || !_active) return;
if (_asking) {
_askAgain = true;
return;
}
unawaited(_ask());
}
void _onActiveChanged() {
if (!_active) {
_timer?.cancel();
@@ -193,7 +216,12 @@ class MailStateWatcher extends Notifier<MailChange> {
// 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다
if (ref.mounted) {
_asking = false;
_schedule(kMailStatePollInterval);
if (_askAgain) {
_askAgain = false;
unawaited(_ask());
} else {
_schedule(kMailStatePollInterval);
}
}
}
}
@@ -0,0 +1,152 @@
import 'dart:async';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 줄의 글 줄 높이 — 보드의 1.55
const double _messageLineHeight = 1.55;
/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(웹 5단계 목록 위 한 번 권하기와 같은 뜻).
///
/// - 보이는 때: 안드로이드 13 이상 · 아직 묻지 않음 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 · 받을 수 있는 휴대폰 · 닫은 적 없음([showsPushOffer]).
/// 위 막대 아래에 고정이다(목록과 함께 흐르지 않는다).
/// - [알림 켜기]를 누를 때만 휴대폰이 묻는 창을 띄운다(두 번 거절하면 다시 뜨지 않는다 — 사용자가 고른 때에만 쓴다).
/// 고른 뒤 알림(③): 허용 → "새 메일 알림을 켰습니다." · 허용 안 함 → "나중에 [설정] 탭에서 …" · 이미 막힘 → 알림에 [설정 열기].
/// 어느 쪽이든 묻고 나면 줄은 사라진다(권한이 "묻지 않음"이 아니게 된다).
/// - [✕]: 이 휴대폰에서 다시 보이지 않는다.
class PushOfferLine extends ConsumerWidget {
const PushOfferLine({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!showsPushOffer(ref.watch(pushControllerProvider))) {
return const SizedBox.shrink();
}
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
explicitChildNodes: true,
label: PushTexts.offerRegion,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: extra.brandTintBorder,
width: AppSize.borderWidth,
),
),
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s16,
AppSpacing.s4,
AppSpacing.s4,
AppSpacing.s4,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s12 + 1),
child: Icon(
Icons.notifications_none,
size: AppSize.iconSmall,
color: scheme.primary,
),
),
Expanded(
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: AppSpacing.s10,
top: AppSpacing.s12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
KeepAllText(
PushTexts.offerMessage,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textStrong,
height: _messageLineHeight,
),
),
Transform.translate(
// 단추 글자가 위 글과 같은 자리에서 시작한다(보드의 margin-left: -12)
offset: const Offset(-AppSpacing.s12, 0),
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
foregroundColor: scheme.onPrimaryContainer,
textStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
onPressed: () => unawaited(_enable(ref)),
child: const Text(PushTexts.offerEnable),
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.close, size: AppSize.iconMedium),
tooltip: PushTexts.offerDismiss,
color: scheme.onSurfaceVariant,
onPressed: () =>
ref.read(pushControllerProvider.notifier).dismissOffer(),
),
],
),
),
),
);
}
/// [알림 켜기] — 휴대폰이 묻는 창 → 고른 대로 알린다. 줄은 묻는 동안 사라지므로 쓸 것을 먼저 쥔다
Future<void> _enable(WidgetRef ref) async {
final PushController push = ref.read(pushControllerProvider.notifier);
final void Function(String text) notice = ref.read(mailNoticeSinkProvider);
final MailToastNotifier toast = ref.read(mailToastProvider.notifier);
Future<void> openSettings() async {
if (!await push.openSettings()) notice(PushTexts.openSettingsFailed);
}
switch (await push.turnOn()) {
case PushTurnOnResult.on:
notice(PushTexts.enabled);
case PushTurnOnResult.denied:
notice(PushTexts.deniedLater);
case PushTurnOnResult.blocked:
toast.offer(
PushTexts.blockedNotice,
actionLabel: PushTexts.openSettings,
onAction: openSettings,
);
case PushTurnOnResult.channelOff:
toast.offer(
PushTexts.channelOffNotice,
actionLabel: PushTexts.openSettings,
onAction: openSettings,
);
case PushTurnOnResult.failed:
case PushTurnOnResult.unavailable:
case PushTurnOnResult.busy:
// 등록 실패는 인터셉터가 사전 문구로 알렸다 — 그 밖은 알릴 것이 없다
break;
}
}
}
@@ -0,0 +1,441 @@
import 'dart:async';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart';
import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:arasystem_app/features/settings/presentation/widgets/settings_notification_row.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 설정 탭 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄).
/// 글자는 테마 크기로 맞췄다(보드 15.5 → 16 · 14 → 15 · 13 → 13 — 앱 디자인 README 의 크기 정리와 같다).
///
/// 한 화면에 절을 모두 둔다: 내 정보(이름 · 주소) · 알림(새 메일 알림) · 다른 모듈이 내놓은 절 · 앱 정보(앱 버전 · 오픈소스 라이선스) ·
/// 문의(`GET /api/support-contact` — 로그인 화면과 같은 저장소, 화면을 열 때 받아 둔다) · [로그아웃].
/// [로그아웃]은 확인 창부터 연다(처음 초점은 [취소] — 로그아웃하면 이 휴대폰의 새 메일 알림도 멈춘다고 알린다).
/// 비밀번호 · 2단계 인증 같은 계정 설정은 웹에서 한다.
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({
required this.licensesLocation,
this.sections = const <ModuleSettingsSection>[],
this.sectionLocation,
super.key,
});
/// 오픈소스 라이선스 화면의 경로
final String licensesLocation;
/// 다른 모듈이 내놓은 설정 절
final List<ModuleSettingsSection> sections;
/// 모듈 절의 경로 — `/settings/<path>`
final String Function(String path)? sectionLocation;
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
@override
void initState() {
super.initState();
unawaited(ref.read(supportContactProvider.notifier).ensureLoaded());
}
Future<void> _signOut() async {
final bool confirmed = await showConfirmDialog(
context,
title: SettingsTexts.signOutTitle,
message: SettingsTexts.signOutMessage,
confirmLabel: SettingsTexts.signOut,
cancelLabel: SettingsTexts.cancel,
);
if (!confirmed || !mounted) return;
await ref.read(sessionProvider.notifier).signOut();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final SessionState? session = ref.watch(sessionProvider).value;
final AuthSessionInfo? me = session is SignedIn ? session.info : null;
final String? version = ref.watch(appVersionProvider);
final String Function(String path)? sectionLocation =
widget.sectionLocation;
return Scaffold(
appBar: AppBar(
toolbarHeight: AppSize.appBarHeight,
titleSpacing: AppSpacing.s20,
automaticallyImplyLeading: false,
shape: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
title: Semantics(header: true, child: const Text(SettingsTexts.home)),
),
body: ListView(
padding: const EdgeInsets.only(bottom: AppSpacing.s24),
children: <Widget>[
_Section(
title: SettingsTexts.account,
child: me == null ? const SizedBox.shrink() : _AccountRow(me: me),
),
const _SectionDivider(),
const _Section(
title: SettingsTexts.notifications,
child: SettingsNotificationRow(),
),
for (final ModuleSettingsSection section
in widget.sections) ...<Widget>[
const _SectionDivider(),
_LinkRow(
label: section.title,
onTap: sectionLocation == null
? null
: () => unawaited(
context.push<void>(sectionLocation(section.path)),
),
),
],
const _SectionDivider(),
_Section(
title: SettingsTexts.about,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_VersionRow(version: version),
_LinkRow(
label: SettingsTexts.licenses,
onTap: () =>
unawaited(context.push<void>(widget.licensesLocation)),
),
],
),
),
const _SectionDivider(),
_Section(
title: SettingsTexts.support,
child: _SupportText(contact: ref.watch(supportContactProvider)),
),
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s20,
AppSpacing.s20,
0,
),
child: _SignOutButton(onPressed: () => unawaited(_signOut())),
),
],
),
);
}
}
/// 절 — 작은 회색 이름(머리) 아래에 칸
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s18,
AppSpacing.s20,
AppSpacing.s6,
),
child: Semantics(
header: true,
child: Text(
title,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textMuted,
),
),
),
),
child,
],
);
}
}
/// 절 사이의 선
class _SectionDivider extends StatelessWidget {
const _SectionDivider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.s8),
child: Divider(
height: AppSize.borderWidth,
color: Theme.of(context).extension<AppExtraColors>()!.borderSubtle,
),
);
}
}
/// 내 정보 — 이름 첫 글자 동그라미 · 이름 · 주소
class _AccountRow extends StatelessWidget {
const _AccountRow({required this.me});
final AuthSessionInfo me;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String name = me.name.trim().isEmpty ? me.email : me.name.trim();
return ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.settingsRowMinHeight,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s20,
vertical: AppSpacing.s6,
),
child: Row(
spacing: AppSpacing.s14,
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.settingsAvatar,
height: AppSize.settingsAvatar,
alignment: Alignment.center,
decoration: BoxDecoration(
color: scheme.primaryContainer,
shape: BoxShape.circle,
),
child: Text(
_initialOf(name),
style: theme.textTheme.titleMedium?.copyWith(
color: scheme.onPrimaryContainer,
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
name,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
Text(
me.email,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
),
),
],
),
),
],
),
),
);
}
}
/// 동그라미의 머리글자 — 이름(없으면 주소)의 첫 글자
String _initialOf(String name) {
final String source = name.trim();
if (source.isEmpty) return '';
return String.fromCharCode(source.runes.first).toUpperCase();
}
/// 앱 버전 — 읽지 못했으면 이름만
class _VersionRow extends StatelessWidget {
const _VersionRow({required this.version});
final String? version;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? value = version;
return MergeSemantics(
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.settingsLineMinHeight,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s20),
child: Row(
spacing: AppSpacing.s12,
children: <Widget>[
Expanded(
child: Text(
SettingsTexts.appVersion,
style: theme.textTheme.bodyLarge,
),
),
if (value != null)
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
fontFeatures: const <FontFeature>[
FontFeature.tabularFigures(),
],
),
),
],
),
),
),
);
}
}
/// 누르면 다른 화면으로 가는 한 줄 — 이름 · 오른쪽 화살표
class _LinkRow extends StatelessWidget {
const _LinkRow({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
button: true,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.settingsLineMinHeight,
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s20,
0,
AppSpacing.s12,
0,
),
child: Row(
spacing: AppSpacing.s12,
children: <Widget>[
Expanded(child: Text(label, style: theme.textTheme.bodyLarge)),
ExcludeSemantics(
child: Icon(
Icons.chevron_right,
size: AppSize.iconMedium,
color: extra.textMuted,
),
),
],
),
),
),
),
);
}
}
/// 문의 — 연락처가 있으면 둘째 줄에 진하게, 없으면 관리자에게 문의해 달라는 글
class _SupportText extends StatelessWidget {
const _SupportText({required this.contact});
final String? contact;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? style = theme.textTheme.bodyMedium?.copyWith(
color: extra.textMuted,
height: AppTypography.lineHeightRelaxed,
);
final String? line = contact;
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s20,
AppSpacing.s2,
AppSpacing.s20,
AppSpacing.s8,
),
child: line == null
? KeepAllText(SettingsTexts.supportFallback, style: style)
: Text.rich(
TextSpan(
children: <InlineSpan>[
TextSpan(
text: '${keepAllWordBreak(SettingsTexts.supportLead)}\n',
),
TextSpan(
text: keepAllWordBreak(line),
style: TextStyle(
color: extra.textStrong,
fontWeight: AppTypography.medium,
),
),
],
),
style: style,
semanticsLabel: '${SettingsTexts.supportLead}\n$line',
),
);
}
}
/// [로그아웃] — 회색 테두리 단추(데이터를 지우는 일이 아니다)
class _SignOutButton extends StatelessWidget {
const _SignOutButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return OutlinedButton.icon(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(AppSize.touchMin),
foregroundColor: extra.textStrong,
side: BorderSide(color: extra.borderStrong),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
textStyle: theme.textTheme.labelLarge,
),
icon: const Icon(Icons.logout, size: AppSize.iconSmall),
label: const Text(SettingsTexts.signOut),
);
}
}
@@ -1,25 +1,33 @@
/// 설정 모듈의 화면 이름 — 지금은 자리 화면이 쓴다.
/// 설정 모듈의 화면 글 — 앱 보드 `App1Shell`(설정 탭 · 로그아웃 확인 창)의 글 그대로다.
/// 새 메일 알림 줄의 글은 받은편지함 권하기 줄과 함께 쓰므로 `core/push/push_texts.dart` 에 있다(보드 `App1Push`).
///
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다(설정 화면 보드 확정 뒤).
/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다.
abstract final class SettingsTexts {
/// 설정 첫 화면
/// 설정 화면 · 탭
static const String home = '설정';
/// 내 정보 — 이름 · 주소
// ── 절 이름 ──
static const String account = '내 정보';
/// 알림 켜기 · 끄기
static const String notifications = '알림';
/// 앱 정보 — 판 · 오픈소스 라이선스
static const String about = '앱 정보';
static const String support = '문의';
// ── 앱 정보 ──
static const String appVersion = '앱 버전';
/// 오픈소스 라이선스 (Flutter 라이선스 화면 — LicenseRegistry)
static const String licenses = '오픈소스 라이선스';
/// 문의 연락처 (`GET /api/support-contact`)
static const String support = '문의 연락처';
// ── 문의 — 연락처(`GET /api/support-contact`)가 있으면 이 글 다음 줄에 연락처 ──
static const String supportLead = '앱이나 계정 문제로 도움이 필요하면 연락해 주세요.';
/// 로그아웃 (화면이 아니라 하는 일)
/// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴)
static const String supportFallback = '앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요.';
// ── 로그아웃 ──
static const String signOut = '로그아웃';
static const String signOutTitle = '로그아웃할까요?';
static const String signOutMessage =
'로그아웃하면 이 휴대폰에서 새 메일 알림도 오지 않습니다. 다음에 쓸 때는 다시 로그인해 주세요.';
static const String cancel = '취소';
}
@@ -0,0 +1,271 @@
import 'dart:async';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/core/theme/app_colors.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 설정 › 새 메일 알림 줄의 한 모습 — 스위치 · 설명 글 · [설정 열기]
@immutable
class NotificationRowView {
const NotificationRowView({
required this.description,
required this.checked,
required this.enabled,
this.busyLabel,
this.opensSettings = false,
});
/// 줄 아래 글
final String description;
/// 스위치가 켜진 쪽인가(켜는 중 · 끄는 중은 바꿀 값)
final bool checked;
/// 스위치를 누를 수 있는가
final bool enabled;
/// 켜는 중 · 끄는 중 — 둥근 진행 표시와 화면 낭독기 글
final String? busyLabel;
/// [설정 열기]를 보이는가(휴대폰에서 막힘 · 새 메일 알림만 꺼짐)
final bool opensSettings;
}
/// 줄 모습 — 앱 보드 `App1Push` ④ 의 여덟 상태(+ 읽는 중)
NotificationRowView notificationRowViewOf(PushRowStatus status) {
return switch (status) {
PushRowStatus.on => const NotificationRowView(
description: PushTexts.rowDescription,
checked: true,
enabled: true,
),
PushRowStatus.off => const NotificationRowView(
description: PushTexts.rowDescription,
checked: false,
enabled: true,
),
PushRowStatus.notAsked => const NotificationRowView(
description: PushTexts.rowNotAsked,
checked: false,
enabled: true,
),
PushRowStatus.turningOn => const NotificationRowView(
description: PushTexts.rowDescription,
checked: true,
enabled: true,
busyLabel: PushTexts.turningOn,
),
PushRowStatus.turningOff => const NotificationRowView(
description: PushTexts.rowDescription,
checked: false,
enabled: true,
busyLabel: PushTexts.turningOff,
),
PushRowStatus.blocked => const NotificationRowView(
description: PushTexts.rowBlocked,
checked: false,
enabled: false,
opensSettings: true,
),
PushRowStatus.channelOff => const NotificationRowView(
description: PushTexts.rowChannelOff,
checked: false,
enabled: false,
opensSettings: true,
),
PushRowStatus.unavailable => const NotificationRowView(
description: PushTexts.rowUnavailable,
checked: false,
enabled: false,
),
PushRowStatus.unsupported => const NotificationRowView(
description: PushTexts.rowUnsupported,
checked: false,
enabled: false,
),
PushRowStatus.loading => const NotificationRowView(
description: PushTexts.rowDescription,
checked: false,
enabled: false,
),
};
}
/// 설정 › 알림의 "새 메일 알림" 줄 — 앱 보드 `App1Push` ④ · `App1Shell`(최소 68 · 스위치 누르는 곳 64×48).
///
/// - 스위치를 켜면 권한이 없을 때 휴대폰이 묻는 창부터 띄우고, 서버 등록이 끝나야 켜진다(실패하면 되돌아가고 사전 문구는 인터셉터가 알린다).
/// 끄면 서버 등록을 지운 뒤 꺼진다. 켜는 동안 · 끄는 동안은 바꿀 값 쪽에 두고 둥근 진행 표시를 보인다.
/// - 휴대폰에서 막혔거나 새 메일 알림만 꺼졌으면 스위치는 누를 수 없고 [설정 열기] — 휴대폰 설정에서 돌아오면 다시 읽어 모습을 바꾼다.
/// - 화면 낭독기는 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다(보드의 aria-labelledby · describedby).
class SettingsNotificationRow extends ConsumerWidget {
const SettingsNotificationRow({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final PushRowStatus status = pushRowStatusOf(
ref.watch(pushControllerProvider),
);
final NotificationRowView view = notificationRowViewOf(status);
final String? busyLabel = view.busyLabel;
return ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.settingsRowMinHeight,
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(
AppSpacing.s20,
AppSpacing.s6,
AppSpacing.s12,
AppSpacing.s6,
),
child: Row(
spacing: AppSpacing.s12,
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: view.opensSettings ? AppSpacing.s8 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: AppSpacing.s2,
children: <Widget>[
// 이름 · 설명은 스위치가 함께 읽는다(두 번 읽히지 않게 글 자체는 빼 둔다)
ExcludeSemantics(
child: Text(
PushTexts.rowLabel,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: AppTypography.medium,
color: theme.colorScheme.onSurface,
),
),
),
ExcludeSemantics(
child: KeepAllText(
view.description,
style: theme.textTheme.bodySmall?.copyWith(
color: view.enabled
? extra.textMuted
: theme.colorScheme.onSurfaceVariant,
),
),
),
if (view.opensSettings) const _OpenSettingsButton(),
],
),
),
),
if (busyLabel != null) _BusyRing(label: busyLabel),
// 스위치 한 마디 — 이름 · 설명 · 켬/끔을 함께 읽는다
Semantics(
container: true,
label: PushTexts.rowLabel,
hint: view.description,
child: SizedBox(
width: AppSize.switchTouchWidth,
height: AppSize.touchMin,
child: Switch(
value: view.checked,
onChanged: view.enabled
? (bool value) => _toggle(ref, value, busy: busyLabel)
: null,
),
),
),
],
),
),
);
}
void _toggle(WidgetRef ref, bool value, {required String? busy}) {
// 켜는 중 · 끄는 중에는 받지 않는다(컨트롤러도 한 번에 하나만 한다)
if (busy != null) return;
final PushController push = ref.read(pushControllerProvider.notifier);
unawaited(value ? push.turnOn() : push.turnOff());
}
}
/// 켜는 중 · 끄는 중 — 둥근 진행 표시(보드: 청록 고리 · 옅은 바탕), 화면 낭독기는 글로 읽는다
class _BusyRing extends StatelessWidget {
const _BusyRing({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
liveRegion: true,
label: label,
child: ExcludeSemantics(
child: SizedBox.square(
dimension: AppSize.progressRing,
child: CircularProgressIndicator(
strokeWidth: AppSize.progressRingStroke,
color: Theme.of(context).colorScheme.primary,
backgroundColor: AppColors.progressRingTrack,
strokeCap: StrokeCap.round,
),
),
),
);
}
}
/// [설정 열기] — 휴대폰 설정(새 메일 알림만 꺼졌으면 그 카테고리)을 연다. 열지 못하면 알린다
class _OpenSettingsButton extends ConsumerWidget {
const _OpenSettingsButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final ThemeData theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.s2),
child: Transform.translate(
// 글자가 위 글과 같은 자리에서 시작한다(보드의 margin-left: -12)
offset: const Offset(-AppSpacing.s12, 0),
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s12),
foregroundColor: theme.colorScheme.onPrimaryContainer,
textStyle: theme.textTheme.labelLarge?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
onPressed: () => unawaited(_open(context, ref)),
child: const Row(
mainAxisSize: MainAxisSize.min,
spacing: AppSpacing.s6,
children: <Widget>[
Text(PushTexts.openSettings),
Icon(Icons.open_in_new, size: AppSize.iconTiny),
],
),
),
),
);
}
Future<void> _open(BuildContext context, WidgetRef ref) async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
final bool opened = await ref
.read(pushControllerProvider.notifier)
.openSettings();
if (opened) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(content: KeepAllText(PushTexts.openSettingsFailed)),
);
}
}
@@ -1,35 +1,19 @@
import 'dart:async';
import 'package:arasystem_app/core/constants/app_constants.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 설정 모듈의 경로 — 모두 `/settings` 아래다
abstract final class SettingsRoutes {
/// 첫 화면 — 설정 목록 (탭 안)
/// 첫 화면 — 설정 한 화면 (탭 안)
static final String home = findAppModule(AppModuleId.settings).rootPath;
/// 내 정보 — 이름 · 주소
static String get account => '$home/account';
/// 알림 켜기 · 끄기
static String get notifications => '$home/notifications';
/// 앱 정보 — 판 · 오픈소스 라이선스
static String get about => '$home/about';
/// 오픈소스 라이선스 (전체 화면)
static String get licenses => '$about/licenses';
/// 문의 연락처
static String get support => '$home/support';
static String get licenses => '$home/licenses';
/// 다른 모듈이 내놓은 설정 절 (`/settings/<path>`)
static String section(String path) => '$home/$path';
@@ -37,50 +21,23 @@ abstract final class SettingsRoutes {
/// 설정 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 첫 판의 절: 내 정보 · 알림 · (모듈이 내놓은 절) · 앱 정보 · 문의 연락처 · 로그아웃.
/// 화면은 자리 화면이다 — 설정 화면 보드가 확정되면 builder 를 `features/settings/presentation/pages/` 의 화면으로 바꾼다.
/// 설정은 한 화면이다(앱 보드 `App1Shell` · `App1Push` ④ — `presentation/pages/settings_page.dart`).
/// 그 아래에는 오픈소스 라이선스(전체 화면)와 다른 모듈이 내놓은 절만 있다.
final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
id: AppModuleId.settings,
buildRoutes: (ModuleRouteContext context) => <RouteBase>[
GoRoute(
path: SettingsRoutes.home,
builder: (BuildContext _, GoRouterState _) =>
_SettingsHomePlaceholder(sections: context.settingsSections),
builder: (BuildContext _, GoRouterState _) => SettingsPage(
licensesLocation: SettingsRoutes.licenses,
sections: context.settingsSections,
sectionLocation: SettingsRoutes.section,
),
routes: <RouteBase>[
GoRoute(
path: 'account',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.account),
),
GoRoute(
path: 'notifications',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.notifications),
),
GoRoute(
path: 'about',
builder: (BuildContext _, GoRouterState _) => PlaceholderPage(
title: SettingsTexts.about,
links: <PlaceholderLink>[
PlaceholderLink(
label: SettingsTexts.licenses,
location: SettingsRoutes.licenses,
),
],
),
routes: <RouteBase>[
GoRoute(
path: 'licenses',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const _LicensesPage(),
),
],
),
GoRoute(
path: 'support',
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: SettingsTexts.support),
path: 'licenses',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) => const _LicensesPage(),
),
for (final ModuleSettingsSection section in context.settingsSections)
GoRoute(path: section.path, builder: section.builder),
@@ -89,47 +46,6 @@ final ModuleRoutes settingsModuleRoutes = ModuleRoutes(
],
);
/// 설정 첫 화면 자리 — 절 이름과 [로그아웃]
class _SettingsHomePlaceholder extends ConsumerWidget {
const _SettingsHomePlaceholder({required this.sections});
final List<ModuleSettingsSection> sections;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PlaceholderPage(
title: SettingsTexts.home,
links: <PlaceholderLink>[
PlaceholderLink(
label: SettingsTexts.account,
location: SettingsRoutes.account,
),
PlaceholderLink(
label: SettingsTexts.notifications,
location: SettingsRoutes.notifications,
),
for (final ModuleSettingsSection section in sections)
PlaceholderLink(
label: section.title,
location: SettingsRoutes.section(section.path),
),
PlaceholderLink(
label: SettingsTexts.about,
location: SettingsRoutes.about,
),
PlaceholderLink(
label: SettingsTexts.support,
location: SettingsRoutes.support,
),
PlaceholderLink(
label: SettingsTexts.signOut,
onTap: () => unawaited(ref.read(sessionProvider.notifier).signOut()),
),
],
);
}
}
/// 오픈소스 라이선스 — Flutter 라이선스 화면(LicenseRegistry — Pretendard OFL 포함)
class _LicensesPage extends ConsumerWidget {
const _LicensesPage();