generated from ttipo/project-base
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user