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
+14 -1
View File
@@ -24,10 +24,23 @@ abstract final class ApiPaths {
/// 문의 연락처 (로그인 없음)
static const String supportContact = '/support-contact';
/// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일")
// ── 앱 설정 · 새 메일 알림 (계약 1절 "앱 새 메일 알림 · 앱 설정") ──
/// 앱 설정 — 최소 판 · 새 메일 알림을 쓸 수 있는지 (로그인 없음)
static const String appConfig = '/app-config';
/// 이 세션의 새 메일 알림 등록 — `PUT`(FID · 플랫폼 · 앱 판) · `DELETE` (앱 토큰만)
static const String pushRegistration = '/push-registrations/current';
/// 이 세션의 "봤음" — `DELETE` 하면 서버가 "새 메일 N통"의 N 을 처음부터 센다
static const String pushRegistrationUnseen =
'/push-registrations/current/unseen';
/// 401 에도 갱신하지 않고 Bearer 도 붙이지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일"),
/// 그리고 로그인 없는 앱 설정(인증 줄 밖의 Dio — `publicDioProvider` 로 보낸다. 줄 안으로 보내더라도 미리 갱신하지 않게)
static const Set<String> noRefresh = <String>{
appLogin,
appRefresh,
appLogout,
appConfig,
};
}
+7 -3
View File
@@ -13,11 +13,11 @@ import 'package:dio/dio.dart';
/// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는
/// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`)
/// → [ErrorNotifyInterceptor](알림 — 맨 끝).
/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다.
/// - 안쪽 Dio(갱신 · 다시 보내기 · 로그인 없는 앱 설정): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다.
///
/// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙).
class DioClient {
DioClient._(this.dio, this.refresher);
DioClient._(this.dio, this.refresher, this.publicDio);
factory DioClient.create({
required ApiConfig config,
@@ -62,7 +62,7 @@ class DioClient {
ErrorNotifyInterceptor(notifier),
]);
return DioClient._(main, refresher);
return DioClient._(main, refresher, bare);
}
/// 화면 · 저장소가 쓰는 Dio
@@ -70,4 +70,8 @@ class DioClient {
/// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나)
final TokenRefresher refresher;
/// 안쪽 Dio — 로그인 없이 앱을 띄우자마자 묻는 요청(앱 설정)이 쓴다. 인증 인터셉터는 요청을 한 줄로 세워
/// 앞 요청의 미리 갱신을 기다리게 하므로, 줄 밖에서 세션 되살리기와 나란히 보낸다. 알림 인터셉터가 없다(알리지 않는 요청만)
final Dio publicDio;
}
+6
View File
@@ -68,6 +68,12 @@ final Provider<Dio> dioProvider = Provider<Dio>(
(Ref ref) => ref.watch(dioClientProvider).dio,
);
/// 로그인 없는 공개 요청의 Dio — 인증 인터셉터 줄 밖(Bearer · 갱신 · 알림 없음, 언래핑 · 코드 정규화만).
/// 앱을 띄우자마자 세션 되살리기와 나란히 묻는 앱 설정(`GET /api/app-config`)이 쓴다
final Provider<Dio> publicDioProvider = Provider<Dio>(
(Ref ref) => ref.watch(dioClientProvider).publicDio,
);
/// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다
final Provider<TokenRefresher> tokenRefresherProvider =
Provider<TokenRefresher>(
+46 -18
View File
@@ -44,46 +44,74 @@ String _clean(String? value) {
: kept;
}
/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다.
Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async {
/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번.
/// 기기 정보를 읽지 못하면 아는 것만 싣는다.
Future<
({
String? appVersion,
String? packageName,
String userAgent,
int? androidSdkInt,
})
>
loadAppDeviceInfo() async {
String? appVersion;
String? packageName;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
final PackageInfo info = await PackageInfo.fromPlatform();
appVersion = info.version;
packageName = info.packageName.isEmpty ? null : info.packageName;
} on PlatformException {
appVersion = null;
}
final ({String userAgent, int? androidSdkInt}) device = await _deviceInfo(
appVersion,
);
return (
appVersion: appVersion,
userAgent: await _deviceUserAgent(appVersion),
packageName: packageName,
userAgent: device.userAgent,
androidSdkInt: device.androidSdkInt,
);
}
Future<String> _deviceUserAgent(String? appVersion) async {
Future<({String userAgent, int? androidSdkInt})> _deviceInfo(
String? appVersion,
) async {
try {
final DeviceInfoPlugin device = DeviceInfoPlugin();
if (Platform.isAndroid) {
final AndroidDeviceInfo android = await device.androidInfo;
return buildUserAgent(
appVersion: appVersion,
osName: 'Android',
osVersion: android.version.release,
model: android.model,
return (
userAgent: buildUserAgent(
appVersion: appVersion,
osName: 'Android',
osVersion: android.version.release,
model: android.model,
),
androidSdkInt: android.version.sdkInt,
);
}
if (Platform.isIOS) {
final IosDeviceInfo ios = await device.iosInfo;
return buildUserAgent(
appVersion: appVersion,
osName: 'iOS',
osVersion: ios.systemVersion,
model: ios.utsname.machine,
return (
userAgent: buildUserAgent(
appVersion: appVersion,
osName: 'iOS',
osVersion: ios.systemVersion,
model: ios.utsname.machine,
),
androidSdkInt: null,
);
}
} on PlatformException {
// 아래의 아는 것만 싣는다
}
return buildUserAgent(
appVersion: appVersion,
osName: Platform.operatingSystem,
return (
userAgent: buildUserAgent(
appVersion: appVersion,
osName: Platform.operatingSystem,
),
androidSdkInt: null,
);
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/foundation.dart';
/// 앱 판 — 숫자 셋(major.minor.patch). 판 이름(`PackageInfo.version` — pubspec 의 build-name, User-Agent 와 같은 값)을 견준다
@immutable
class AppVersion implements Comparable<AppVersion> {
const AppVersion(this.major, this.minor, this.patch);
/// 판 이름을 읽는다 — `1.2` 는 `1.2.0`, `-` · `+` 뒤(미리 보기 꼬리 · 빌드 번호)는 버린다.
/// 칸이 넷 이상이거나 숫자가 아니면(칸마다 1~4자리) 읽지 못한 것(null)이다 — 칸마다 4자리는 서버의 판 칸(`APP_VERSION_PATTERN`)과 같다.
/// 5자리 칸을 받으면 서버 등록(`PUT`)이 늘 400 이 된다
static AppVersion? tryParse(String? text) {
if (text == null) return null;
String value = text.trim();
final int tail = value.indexOf(RegExp('[-+]'));
if (tail >= 0) value = value.substring(0, tail);
final List<String> parts = value.split('.');
if (parts.isEmpty || parts.length > 3) return null;
final List<int> numbers = <int>[];
for (final String part in parts) {
if (!_part.hasMatch(part)) return null;
numbers.add(int.parse(part));
}
while (numbers.length < 3) {
numbers.add(0);
}
return AppVersion(numbers[0], numbers[1], numbers[2]);
}
static final RegExp _part = RegExp(r'^\d{1,4}$');
final int major;
final int minor;
final int patch;
@override
int compareTo(AppVersion other) {
if (major != other.major) return major.compareTo(other.major);
if (minor != other.minor) return minor.compareTo(other.minor);
return patch.compareTo(other.patch);
}
bool operator <(AppVersion other) => compareTo(other) < 0;
@override
bool operator ==(Object other) =>
other is AppVersion &&
other.major == major &&
other.minor == minor &&
other.patch == patch;
@override
int get hashCode => Object.hash(major, minor, patch);
@override
String toString() => '$major.$minor.$patch';
}
/// 서버에 알리는 앱 판 — `숫자.숫자.숫자`(서버 칸의 모양). 읽지 못하면 null
String? normalizedAppVersion(String? version) =>
AppVersion.tryParse(version)?.toString();
/// 앱 설정 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정", 로그인 없음)
///
/// `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: true }` — 앱은 자기 플랫폼의 최소 판만 읽는다.
@immutable
class AppConfig {
const AppConfig({this.minSupportedVersion, this.pushAvailable});
/// 그 경로가 없는 옛 서버(404) — 막지 않고, 새 메일 알림은 쓸 수 없는 것으로 본다
static const AppConfig notProvided = AppConfig(pushAvailable: false);
/// 응답의 `data` 를 읽는다 — 모양이 틀린 칸은 없는 것으로 본다(최소 판이 틀리면 막지 않는다 · 알림 칸이 틀리면 모른다)
factory AppConfig.fromJson(Object? data, {String platform = kAppPlatform}) {
if (data is! Map<String, Object?>) return const AppConfig();
final Object? versions = data['minSupportedVersion'];
final Object? minimum = versions is Map<String, Object?>
? versions[platform]
: null;
final Object? push = data['pushAvailable'];
return AppConfig(
minSupportedVersion: minimum is String
? AppVersion.tryParse(minimum)
: null,
pushAvailable: push is bool ? push : null,
);
}
/// 이 판보다 낮은 앱은 쓸 수 없다 — 없으면(정하지 않음 · 읽지 못함) 막지 않는다
final AppVersion? minSupportedVersion;
/// 서버가 새 메일 알림을 보낼 수 있다 — 모르면 null
final bool? pushAvailable;
@override
bool operator ==(Object other) =>
other is AppConfig &&
other.minSupportedVersion == minSupportedVersion &&
other.pushAvailable == pushAvailable;
@override
int get hashCode => Object.hash(minSupportedVersion, pushAvailable);
}
/// 앱이 읽는 플랫폼 칸 — iOS 를 붙이면 플랫폼마다 고른다
const String kAppPlatform = 'android';
/// 업데이트해야 하는가 — 이 앱의 판이 서버의 최소 판보다 낮을 때만. 어느 쪽이든 읽지 못하면 막지 않는다
bool isAppUpdateRequired({
required String? appVersion,
required AppVersion? minimum,
}) {
if (minimum == null) return false;
final AppVersion? current = AppVersion.tryParse(appVersion);
if (current == null) return false;
return current < minimum;
}
@@ -0,0 +1,138 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/app_config/app_config.dart';
import 'package:arasystem_app/core/app_config/app_config_repository.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱 설정을 마지막으로 받은 뒤 이만큼 지나면 앞으로 올 때 다시 묻는다
const Duration kAppConfigRefreshAfter = Duration(hours: 1);
/// 받지 못했으면(끊긴 망 · 5xx) 앞으로 올 때 이만큼 간격으로 다시 묻는다
const Duration kAppConfigRetryAfter = Duration(minutes: 1);
/// 앱 설정 상태 — 받은 값과 받은 때 · 실패한 때(기기 시계 — 간격만 잰다)
@immutable
class AppConfigState {
const AppConfigState({this.config, this.fetchedAt, this.failedAt});
/// 마지막으로 받은 값 — 아직 받지 못했으면 null(막지 않는다 · 알림은 쓸 수 있는 것으로)
final AppConfig? config;
final DateTime? fetchedAt;
/// 마지막 실패 — 그 뒤에 받았으면 의미가 없다
final DateTime? failedAt;
@override
bool operator ==(Object other) =>
other is AppConfigState &&
other.config == config &&
other.fetchedAt == fetchedAt &&
other.failedAt == failedAt;
@override
int get hashCode => Object.hash(config, fetchedAt, failedAt);
}
/// 앱 설정 API
final Provider<AppConfigRepository> appConfigRepositoryProvider =
Provider<AppConfigRepository>(
(Ref ref) => AppConfigRepository(ref.watch(publicDioProvider)),
);
/// 앱 설정 — 앱에 하나. 라우터(최소 판)와 새 메일 알림(`pushAvailable`)이 본다.
final NotifierProvider<AppConfigController, AppConfigState> appConfigProvider =
NotifierProvider<AppConfigController, AppConfigState>(
AppConfigController.new,
retry: noAutomaticRetry,
);
/// 앱 설정을 묻는다 — 앱을 띄울 때 한 번(세션 되살리기와 나란히 — 기다리지 않는다), 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면.
///
/// 실패(끊긴 망 · 5xx)는 알리지 않고 막지 않는다 — 앞으로 올 때 1분 간격으로 다시. 404(그 경로가 없는 옛 서버)는 받은 것으로 본다
/// (최소 판 없음 · 알림 쓸 수 없음). 모양이 틀리면 막지 않는다.
class AppConfigController extends Notifier<AppConfigState> {
Future<void>? _loading;
@override
AppConfigState build() {
ref.listen<bool>(appForegroundProvider, (bool? previous, bool next) {
if (next && previous == false) refreshIfStale();
});
// 만들자마자 묻는다 — build 안에서는 상태를 바꾸지 않는다
final Ref built = ref;
scheduleMicrotask(() {
if (built.mounted) unawaited(refresh());
});
return const AppConfigState();
}
/// 지금 묻는다 — 묻는 중이면 그 물음을 함께 기다린다
Future<void> refresh() {
final Future<void>? running = _loading;
if (running != null) return running;
final Future<void> started = _load();
_loading = started;
return started.whenComplete(() {
if (identical(_loading, started)) _loading = null;
});
}
/// 앞으로 왔다 — 받은 지 1시간이 지났거나, 마지막이 실패였고 1분이 지났으면 묻는다
void refreshIfStale() {
if (!ref.mounted || _loading != null) return;
final DateTime now = ref.read(clockProvider)();
final DateTime? fetched = state.fetchedAt;
final DateTime? failed = state.failedAt;
final bool lastFailed =
failed != null && (fetched == null || failed.isAfter(fetched));
final DateTime? since = lastFailed ? failed : fetched;
final Duration wait = lastFailed
? kAppConfigRetryAfter
: kAppConfigRefreshAfter;
final Duration? elapsed = since == null ? null : now.difference(since);
// 시계를 뒤로 돌렸다(음수) — 지난 시간을 모르니 묻는다
if (elapsed == null || elapsed.isNegative || elapsed >= wait) {
unawaited(refresh());
}
}
Future<void> _load() async {
final Ref ref = this.ref;
try {
final AppConfig config = await ref
.read(appConfigRepositoryProvider)
.fetch();
if (!ref.mounted) return;
state = AppConfigState(
config: config,
fetchedAt: ref.read(clockProvider)(),
);
} on DioException {
if (!ref.mounted) return;
state = AppConfigState(
config: state.config,
fetchedAt: state.fetchedAt,
failedAt: ref.read(clockProvider)(),
);
}
}
}
/// 이 앱을 업데이트해야 하는가 — 라우터가 어느 화면에서든 업데이트 화면으로 옮긴다(`core/router/session_redirect.dart`)
final Provider<bool> appUpdateRequiredProvider = Provider<bool>(
(Ref ref) => isAppUpdateRequired(
appVersion: ref.watch(appVersionProvider),
minimum: ref.watch(appConfigProvider).config?.minSupportedVersion,
),
);
/// 서버가 새 메일 알림을 보낼 수 있는가 — 모르면 null(쓸 수 있는 것으로 본다)
final Provider<bool?> pushAvailableProvider = Provider<bool?>(
(Ref ref) => ref.watch(appConfigProvider).config?.pushAvailable,
);
@@ -0,0 +1,32 @@
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/app_config/app_config.dart';
import 'package:dio/dio.dart';
/// 그 경로가 없을 때 서버 · 앞단 nginx 가 내는 코드 — 옛 서버로 본다
const String _notFoundCode = 'RES_001';
/// 앱 설정 — `GET /api/app-config` (로그인 없음 · 알림 끔).
///
/// 인증 인터셉터 줄 밖의 Dio(`publicDioProvider`)로 보낸다 — 앱을 띄우자마자 세션 되살리기(미리 갱신)를 기다리지 않고 나란히 묻는다.
/// 줄 안의 Dio 로 보내더라도 Bearer 를 붙이지 않고 미리 갱신하지 않는 경로다(`ApiPaths.noRefresh`).
class AppConfigRepository {
const AppConfigRepository(this._dio);
final Dio _dio;
/// 받는다 — 그 경로가 없으면(404 `RES_001` — 옛 서버) [AppConfig.notProvided]. 그 밖의 실패는 `DioException`
Future<AppConfig> fetch() async {
try {
final Response<Object?> response = await _dio.get<Object?>(
ApiPaths.appConfig,
options: ApiOptions.silent(),
);
return AppConfig.fromJson(response.data);
} on DioException catch (error) {
if (apiErrorCodeOf(error) == _notFoundCode) return AppConfig.notProvided;
rethrow;
}
}
}
@@ -5,3 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
final Provider<String?> appVersionProvider = Provider<String?>(
(Ref ref) => null,
);
/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다.
/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다)
final Provider<String?> appPackageNameProvider = Provider<String?>(
(Ref ref) => null,
);
@@ -17,4 +17,7 @@ abstract final class ScreenTitles {
/// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup)
static const String twoFactorSetup = '2단계 인증을 켜 주세요';
/// 앱을 업데이트해야 한다 — 업데이트 화면의 제목(보드 `App1Push` ⑩)
static const String update = '앱을 업데이트해 주세요';
}
@@ -0,0 +1,123 @@
import 'dart:async';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
/// Firebase 를 띄우는 데 기다리는 가장 긴 시간 — 넘으면 알림 없이 앱을 띄운다
const Duration kFirebaseStartTimeout = Duration(seconds: 5);
/// 알림 권한을 묻기 시작한 안드로이드 판 — 13(API 33). 그 아래는 묻는 창이 없고 휴대폰 설정에서만 켜고 끈다
const int kNotificationPermissionSdk = 33;
/// firebase_messaging 의 권한 → 앱의 [PushPermission] (firebase_messaging 17 — 안드로이드는 플러그인이 "물은 적 있음"을 기록해 넷으로 가른다).
///
/// - 허락 · 임시 허락(iOS) → granted
/// - 묻지 않음 → notAsked
/// - 거절 → 안드로이드 13 이상은 다시 물을 수 있어 denied, 12 이하는 휴대폰 설정에서 끈 것이라 blocked.
/// 판을 모르면([sdkInt] null) 다시 물을 수 있는 것으로 본다(묻는 창이 뜨지 않으면 그 결과가 진짜 상태다)
/// - 다시 묻지 못함 → blocked
PushPermission pushPermissionOf(AuthorizationStatus status, {int? sdkInt}) {
final bool canAskAgain =
sdkInt == null || sdkInt >= kNotificationPermissionSdk;
return switch (status) {
AuthorizationStatus.authorized ||
AuthorizationStatus.provisional => PushPermission.granted,
AuthorizationStatus.notDetermined => PushPermission.notAsked,
AuthorizationStatus.denied =>
canAskAgain ? PushPermission.denied : PushPermission.blocked,
AuthorizationStatus.deniedPermanently => PushPermission.blocked,
};
}
/// FCM 메시지 → [PushMessage] — data 의 모듈 · 종류만 읽는다(알림 글 · 보낸 쪽 정보는 버린다)
PushMessage pushMessageOf(RemoteMessage message) =>
PushMessage.fromData(Map<String, Object?>.of(message.data));
/// firebase_messaging 으로 만든 [PushMessaging] — 앱에서 firebase_messaging 을 import 하는 곳은 여기뿐이다.
///
/// 플러그인의 소식 줄(FID · 앞에서 받은 알림 · 누른 알림)은 만들자마자 듣는다 — 플러그인 줄은 broadcast 라 듣기 전에 온 값은 사라진다.
/// 기기 주소(FID) · 알림 내용은 로그에 남기지 않는다.
class FirebasePushMessaging implements PushMessaging {
FirebasePushMessaging._(this._messaging, {int? sdkInt}) : _sdkInt = sdkInt {
_subscriptions.addAll(<StreamSubscription<Object?>>[
_messaging.onRegistered.listen(_registered.add),
FirebaseMessaging.onMessage.listen(
(RemoteMessage message) => _foreground.add(pushMessageOf(message)),
),
FirebaseMessaging.onMessageOpenedApp.listen(
(RemoteMessage message) => _opened.add(pushMessageOf(message)),
),
]);
}
/// Firebase 를 띄운다 — 안드로이드는 google-services 플러그인이 만든 리소스(`google-services.json`)에서 설정을 읽는다.
/// 띄우지 못하면(설정 파일 없이 빌드 · [timeout] 넘음) 알림 없이 도는 [UnavailablePushMessaging] 을 돌려준다 — 앱은 그대로 돈다.
/// [sdkInt] 는 안드로이드 판(API) — 권한을 넷으로 가르는 데 쓴다(모르면 null).
static Future<PushMessaging> start({
int? sdkInt,
Duration timeout = kFirebaseStartTimeout,
}) async {
try {
await Firebase.initializeApp().timeout(timeout);
// 플러그인의 채널 처리기를 지금 건다 — 누른 알림 · 앞에서 받은 알림을 놓치지 않게
return FirebasePushMessaging._(
FirebaseMessaging.instance,
sdkInt: sdkInt,
);
} on Object {
// "Failed to load FirebaseOptions from resource"(설정 파일 없음) · 시간 넘음 · 플러그인 없음
return const UnavailablePushMessaging();
}
}
final FirebaseMessaging _messaging;
final int? _sdkInt;
// 앱이 떠 있는 동안 내내 듣는다(닫지 않는다)
final List<StreamSubscription<Object?>> _subscriptions =
<StreamSubscription<Object?>>[];
final StreamController<String> _registered =
StreamController<String>.broadcast();
final StreamController<PushMessage> _foreground =
StreamController<PushMessage>.broadcast();
final StreamController<PushMessage> _opened =
StreamController<PushMessage>.broadcast();
@override
bool get isAvailable => true;
@override
Future<PushPermission> permission() async {
final NotificationSettings settings = await _messaging
.getNotificationSettings();
return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt);
}
@override
Future<PushPermission> requestPermission() async {
final NotificationSettings settings = await _messaging.requestPermission();
return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt);
}
@override
Future<void> register() => _messaging.register();
@override
Future<void> unregister() => _messaging.unregister();
@override
Stream<String> get onRegistered => _registered.stream;
@override
Stream<PushMessage> get onForegroundMessage => _foreground.stream;
@override
Stream<PushMessage> get onOpened => _opened.stream;
@override
Future<PushMessage?> initialOpen() async {
final RemoteMessage? message = await _messaging.getInitialMessage();
return message == null ? null : pushMessageOf(message);
}
}
@@ -0,0 +1,127 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 휴대폰의 알림 설정 — 앱 알림 전체 · 새 메일 채널 · 안드로이드 판
@immutable
class NotificationStatus {
const NotificationStatus({
required this.appEnabled,
required this.channelEnabled,
this.sdkInt,
});
/// 읽지 못했다 — 막지 않는 쪽(켜짐)으로 본다
static const NotificationStatus unknown = NotificationStatus(
appEnabled: true,
channelEnabled: true,
);
/// 이 앱의 알림 전체가 켜져 있다(안드로이드 `areNotificationsEnabled`)
final bool appEnabled;
/// 새 메일 채널이 켜져 있다 — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다(채널이 없는 판은 참)
final bool channelEnabled;
/// 안드로이드 판(API) — 모르면 null
final int? sdkInt;
@override
bool operator ==(Object other) =>
other is NotificationStatus &&
other.appEnabled == appEnabled &&
other.channelEnabled == channelEnabled &&
other.sdkInt == sdkInt;
@override
int get hashCode => Object.hash(appEnabled, channelEnabled, sdkInt);
}
/// 휴대폰의 알림창 · 알림 설정 — 포트다(안드로이드는 [PlatformNotificationSystem], iOS 는 나중에 구현을 더한다)
abstract interface class NotificationSystem {
/// 지금 알림 설정 — 읽지 못하면 [NotificationStatus.unknown]
Future<NotificationStatus> status();
/// 휴대폰의 알림 설정 화면을 연다 — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참
Future<bool> openSettings();
/// 알림창의 "새 메일 N통"을 지운다 — 다른 알림은 건드리지 않는다
Future<void> clearNewMail();
}
/// 안드로이드 알림 채널(`MainActivity.kt` → `NewMailNotifications.kt`)
///
/// **짝: Kotlin `NewMailNotifications`** — 채널 이름 · 부르는 이름 · 답의 칸과 값([statusMethod] 의 [appEnabledKey] · [channelEnabledKey] · [sdkIntKey],
/// [openSettingsMethod] 의 [openedResult]). 한쪽을 고치면 둘 다.
class PlatformNotificationSystem implements NotificationSystem {
const PlatformNotificationSystem();
static const MethodChannel channel = MethodChannel(
'kr.it.arasystem.app/notifications',
);
static const String statusMethod = 'status';
static const String openSettingsMethod = 'openSettings';
static const String clearNewMailMethod = 'clearNewMail';
static const String appEnabledKey = 'appEnabled';
static const String channelEnabledKey = 'channelEnabled';
static const String sdkIntKey = 'sdkInt';
static const String openedResult = 'opened';
@override
Future<NotificationStatus> status() async {
try {
final Map<Object?, Object?>? reply = await channel
.invokeMapMethod<Object?, Object?>(statusMethod);
return notificationStatusOf(reply);
} on PlatformException {
return NotificationStatus.unknown;
} on MissingPluginException {
return NotificationStatus.unknown;
}
}
@override
Future<bool> openSettings() async {
try {
return await channel.invokeMethod<String>(openSettingsMethod) ==
openedResult;
} on PlatformException {
return false;
} on MissingPluginException {
return false;
}
}
@override
Future<void> clearNewMail() async {
try {
await channel.invokeMethod<void>(clearNewMailMethod);
} on PlatformException {
// 지우지 못했다 — 다음에 앞으로 올 때 다시 지운다
} on MissingPluginException {
// 채널이 없는 플랫폼
}
}
}
/// 채널 답 → [NotificationStatus] — 모양이 틀린 칸은 막지 않는 쪽(켜짐 · 판 모름)으로 본다
NotificationStatus notificationStatusOf(Map<Object?, Object?>? reply) {
if (reply == null) return NotificationStatus.unknown;
final Object? app = reply[PlatformNotificationSystem.appEnabledKey];
final Object? channel = reply[PlatformNotificationSystem.channelEnabledKey];
final Object? sdkInt = reply[PlatformNotificationSystem.sdkIntKey];
return NotificationStatus(
appEnabled: app is bool ? app : true,
channelEnabled: channel is bool ? channel : true,
sdkInt: sdkInt is int ? sdkInt : null,
);
}
/// 알림창 · 알림 설정 — 시험은 가짜로 덮어쓴다
final Provider<NotificationSystem> notificationSystemProvider =
Provider<NotificationSystem>(
(Ref ref) => const PlatformNotificationSystem(),
);
+678
View File
@@ -0,0 +1,678 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/error_notifier.dart';
import 'package:arasystem_app/core/app_config/app_config.dart';
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/push/notification_system.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_registration.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
import 'package:arasystem_app/core/storage/token_store_provider.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/clock.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// "봤음"(`DELETE …/unseen`)을 보내는 가장 짧은 간격 — 그 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다
const Duration kPushSeenInterval = Duration(seconds: 30);
/// 직원이 켤 때 FCM 등록 소식(FID)을 기다리는 가장 긴 시간 — 넘으면 켜지 못한 것으로 알린다
const Duration kPushRegisterWait = Duration(seconds: 15);
/// 새 메일 알림 — 앱에 하나. 세션 노티파이어가 [PushRegistrar] 로 부르고, 설정 › 새 메일 알림 줄 · 받은편지함 위 권하기 줄이 상태를 본다.
final NotifierProvider<PushController, PushState> pushControllerProvider =
NotifierProvider<PushController, PushState>(PushController.new);
/// 새 메일 알림 컨트롤러 — 계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘".
///
/// - **맞추기**: "보내야 하나"(켬 ∧ 권한 ∧ 새 메일 채널 — [pushWanted])와 "맞출 수 있나"(받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음 ∧
/// 메일을 쓸 수 있는 세션 — [pushReachable])를 보고 FCM 등록 · `PUT` · `DELETE` 를 고른다. 서버 일은 한 번에 하나씩(줄 세움).
/// `PUT` 은 FID 를 받았을 때 · FID 가 바뀌었을 때 · 세션이 열릴 때 한 번(앱을 띄울 때 — 판 · 줄 되살리기) · 직원이 켤 때만 — 앞으로 올 때마다 부르지 않는다.
/// `DELETE` 는 직원이 끌 때 · 권한이나 채널이 꺼진 것을 알았을 때. 로그아웃 · 세션 끝에는 부르지 않는다 — 세션 노티파이어가 토큰을 지운 뒤
/// 서버 logout 과 함께 [unregister] 를 부르고(서로 기다리지 않는다), 여기서는 알림창을 지우고 FCM 등록만 푼다(서버 줄은 서버가 세션과 함께 지운다).
/// - **세션**: 세션 세대([_epoch] — register · unregister · pause 마다 오른다)가 바뀌면 늦은 결과를 버린다. 세션 노티파이어는 이것을 [pushRegistrarProvider] 로만 알고,
/// 이것은 세션을 watch 하지 않는다(서로 물리지 않게).
/// - **FCM 등록 세대**([_fcmGeneration] — 풀기를 시작할 때마다 오른다): 풀기 앞의 등록은 끝나도 마친 것으로 치지 않고 "풀지 못한 등록" 표시도 지우지 않는다.
/// 다음 세션의 등록은 풀기 뒤에 줄을 선다 — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 다시 등록한다.
/// - **앞으로 옴**: 알림창의 "새 메일 N통"을 지우고, 권한 · 채널을 다시 읽어 맞추고, "봤음"을 보낸다(30초 간격 — 건너뛰면 끝에 한 번).
/// 앞에 있는 동안 온 새 메일 알림도 "봤음"을 보내고 메일 모듈에 알린다([pushEventsProvider] — 새 메일 확인을 바로).
/// 봤음은 앱이 앞에 있을 때만 보낸다 — 간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다).
/// - **누른 알림**: [pushEventsProvider] 에 값으로 올린다 — 라우터가 가져간다(`core/router/push_navigation.dart`).
/// - Firebase 를 띄우지 못한 휴대폰([PushMessaging.isAvailable] 이 거짓)에서는 아무것도 하지 않는다.
/// - FID · 주소 · 토큰은 로그에 남기지 않는다.
class PushController extends Notifier<PushState> implements PushRegistrar {
late PushMessaging _messaging;
late NotificationSystem _notifications;
late PushPreferences _prefs;
final List<StreamSubscription<Object?>> _subscriptions =
<StreamSubscription<Object?>>[];
/// 이 휴대폰의 값 · 권한을 다 읽었다 — 맞추기 · 켜기 · 끄기가 먼저 기다린다
Future<void> _ready = Future<void>.value();
/// 서버 일 · 기기 읽기를 차례로
Future<void> _queue = Future<void>.value();
bool _reconcileQueued = false;
/// FCM 등록 · 풀기를 차례로(플러그인은 둘을 다른 스레드에서 돌린다)
Future<void> _fcmQueue = Future<void>.value();
/// 세션 세대 — register · unregister · pause 마다 오른다
int _epoch = 0;
bool _sessionReady = false;
/// 이 세션의 서버 줄 — 있음(true) · 없음(false) · 모름(null)
bool? _serverRow = false;
/// 이 세션에 이번 실행에서 올린 FID — 세션이 열릴 때마다 비운다(그 세션에서 한 번은 올린다)
String? _serverFid;
/// FCM 이 알려 준 FID — 또는 지난 실행에 올린 것(FCM 등록이 그대로인 동안)
String? _fid;
/// FCM 등록을 바란다(맞추기가 시작했다 — 풀 때까지) · 이번 실행에서 등록을 마쳤다
bool _fcmWanted = false;
bool _fcmRegistered = false;
Future<void>? _fcmRegistering;
bool _pendingUnregister = false;
/// FCM 등록 세대 — 풀기를 시작할 때마다 오른다. 등록은 시작한 세대가 그대로일 때만 마친 것으로 친다
int _fcmGeneration = 0;
/// 줄에 섰거나 도는 풀기의 수 — 그동안 온 등록 소식은 풀리는 등록의 것이라 쓰지 않는다
int _fcmUnregistering = 0;
final List<Completer<String?>> _fidWaiters = <Completer<String?>>[];
// ── 봤음 ──
bool _seenPending = false;
DateTime? _lastSeenAt;
Timer? _seenTimer;
PushRegistrationRepository get _repository =>
ref.read(pushRegistrationRepositoryProvider);
TokenStore get _store => ref.read(tokenStoreProvider);
Clock get _clock => ref.read(clockProvider);
ErrorNotifier get _errors => ref.read(errorNotifierProvider);
String get _appVersion =>
normalizedAppVersion(ref.read(appVersionProvider)) ?? kUnknownAppVersion;
@override
PushState build() {
_messaging = ref.read(pushMessagingProvider);
_notifications = ref.read(notificationSystemProvider);
_prefs = ref.read(pushPreferencesProvider);
ref.onDispose(_dispose);
if (!_messaging.isAvailable) return const PushState(loaded: true);
_subscriptions.addAll(<StreamSubscription<Object?>>[
_messaging.onRegistered.listen(_onRegistered),
_messaging.onForegroundMessage.listen(_onForegroundMessage),
_messaging.onOpened.listen(_onOpened),
]);
ref
..listen<bool>(appForegroundProvider, (bool? previous, bool next) {
if (next && previous == false) _cameToForeground();
if (!next && previous == true) _wentToBackground();
})
..listen<bool?>(pushAvailableProvider, (bool? previous, bool? next) {
state = state.copyWith(server: _serverOf(next));
_requestReconcile();
});
// 꺼져 있던 앱을 띄운 알림 — 라우터가 가져간다
unawaited(
_messaging.initialOpen().then<void>((PushMessage? opened) {
if (opened != null && ref.mounted) {
ref.read(pushEventsProvider.notifier).add(opened, opened: true);
}
}, onError: (Object _, StackTrace _) {}),
);
// build 안에서는 상태를 바꾸지 않는다 — 다음 차례에 읽기 시작한다
_ready = Future<void>.microtask(_initialize);
return PushState(
deviceSupported: true,
server: _serverOf(ref.read(pushAvailableProvider)),
);
}
// ── 화면이 부르는 일 ───────────────────────────────
/// [알림 켜기](권하기 줄) · 설정 스위치를 켰다 — 권한이 없으면 먼저 시스템 창(직원이 누른 뒤에만). 허락되면 FCM 등록 → 서버 등록(`PUT`)이
/// 끝나야 켬을 둔다. 서버 등록이 실패하면 이 휴대폰의 값을 끔으로 두고(스위치가 꺼진 채로 돌아간다 — 처음 값 켬에서 켰어도, 저절로 다시 하지 않는다)
/// 그 코드의 사전 문구를 알린다(인터셉터 · 등록 소식이 없으면 `SYS_001`)
Future<PushTurnOnResult> turnOn() async {
await _ready;
if (!ref.mounted) return PushTurnOnResult.failed;
if (state.change != null) return PushTurnOnResult.busy;
if (!state.deviceSupported ||
state.server == PushServer.unavailable ||
!_sessionReady) {
return PushTurnOnResult.unavailable;
}
final int epoch = _epoch;
state = state.copyWith(change: PushChange.turningOn);
try {
PushPermission permission = await _readPermission();
if (!_current(epoch)) return PushTurnOnResult.failed;
if (permission != PushPermission.granted) {
if (permission == PushPermission.blocked) {
state = state.copyWith(permission: permission);
return PushTurnOnResult.blocked;
}
permission = await _askPermission();
if (!ref.mounted) return PushTurnOnResult.failed;
state = state.copyWith(permission: permission);
if (permission != PushPermission.granted) {
return permission == PushPermission.blocked
? PushTurnOnResult.blocked
: PushTurnOnResult.denied;
}
}
final NotificationStatus status = await _notifications.status();
if (!ref.mounted) return PushTurnOnResult.failed;
state = state.copyWith(
permission: permission,
channelEnabled: status.channelEnabled,
sdkInt: status.sdkInt,
);
if (!status.channelEnabled) return PushTurnOnResult.channelOff;
// 켬 · 끔은 줄 안에서 둔다 — 뒤에 선 맞추기가 바뀐 값을 보게
final _UserRegistration outcome = await _serial(() async {
final _UserRegistration result = await _registerByUser(epoch);
if (!ref.mounted) return _UserRegistration.skipped;
switch (result) {
case _UserRegistration.registered:
state = state.copyWith(enabled: true);
unawaited(_prefs.setEnabled(true));
case _UserRegistration.failed:
// 직원이 켠 것이 실패했다 — 끔으로 둔다(스위치 · 줄이 꺼짐으로 돌아가고 저절로 다시 하지 않는다)
state = state.copyWith(enabled: false);
unawaited(_prefs.setEnabled(false));
case _UserRegistration.skipped:
break;
}
return result;
});
return outcome == _UserRegistration.registered
? PushTurnOnResult.on
: PushTurnOnResult.failed;
} finally {
if (ref.mounted) state = state.copyWith(clearChange: true);
}
}
/// 설정 스위치를 껐다 — 서버 등록을 지워야(`DELETE`) 끔을 둔다. 지우지 못하면 켬 그대로(스위치는 되돌린다 — 사전 문구는 인터셉터가 알렸다).
/// 끊긴 망에서는 끌 수 없다 — 휴대폰 설정에서 끌 수 있다
Future<PushTurnOffResult> turnOff() async {
await _ready;
if (!ref.mounted) return PushTurnOffResult.failed;
if (state.change != null) return PushTurnOffResult.busy;
final int epoch = _epoch;
state = state.copyWith(change: PushChange.turningOff);
try {
// 줄 안에서 본다 — 앞에 선 등록(PUT)이 끝난 뒤의 서버 줄로 정하고, 끔도 줄 안에서 둔다
final bool done = await _serial(() async {
final bool talk =
_sessionReady && _store.hasSession && state.deviceSupported;
if (talk && _serverRow != false) {
if (!await _delete(epoch: epoch, silent: false)) return false;
}
if (!ref.mounted) return false;
state = state.copyWith(enabled: false);
unawaited(_prefs.setEnabled(false));
return true;
});
return done ? PushTurnOffResult.off : PushTurnOffResult.failed;
} finally {
if (ref.mounted) state = state.copyWith(clearChange: true);
}
}
/// 받은편지함 위 권하기 줄의 [✕] — 이 휴대폰에서 다시 보이지 않는다
void dismissOffer() {
if (!ref.mounted || state.offerDismissed) return;
state = state.copyWith(offerDismissed: true);
unawaited(_prefs.setOfferDismissed(true));
}
/// [설정 열기] — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참(돌아오면 앞으로 옴이 다시 읽는다)
Future<bool> openSettings() => _notifications.openSettings();
// ── 세션 노티파이어가 부르는 일 (PushRegistrar) ──────
@override
Future<void> register() async {
// 세션 노티파이어가 상태를 바꾸는 중에 부를 수 있다 — 다음 차례에 바꾼다
await Future<void>.value();
if (!ref.mounted || !state.deviceSupported) return;
_epoch++;
_sessionReady = true;
// 세션이 열릴 때마다 한 번은 올린다(앱을 띄울 때 — 판 · 줄 되살리기)
_serverFid = null;
if (ref.read(appForegroundProvider)) _seenPending = true;
state = state.copyWith(sessionReady: true);
_requestReconcile();
}
@override
Future<void> unregister() async {
if (!ref.mounted) return;
_epoch++;
final int epoch = _epoch;
_sessionReady = false;
_serverFid = null;
_cancelSeen();
_completeFidWaiters(null);
if (!state.deviceSupported) return;
// 지난 실행의 값(FID · 풀지 못한 등록)을 읽은 뒤에 정한다
await _ready;
// 그사이 다음 세션이 열렸다(다른 직원의 로그인) — 그 세션이 등록한다
if (!ref.mounted || epoch != _epoch) return;
final bool hadRegistration =
_fcmWanted || _fcmRegistered || _fid != null || _pendingUnregister;
_fcmWanted = false;
_fid = null;
_serverRow = false;
state = state.copyWith(sessionReady: false, registered: false);
unawaited(_prefs.setLastFid(null));
unawaited(_prefs.setServerRegistered(false));
await _notifications.clearNewMail();
if (hadRegistration && ref.mounted && epoch == _epoch) {
await _unregisterFcm();
}
}
@override
void pause() {
if (!ref.mounted) return;
_epoch++;
_sessionReady = false;
_serverFid = null;
_cancelSeen();
if (!state.deviceSupported) return;
scheduleMicrotask(() {
if (ref.mounted && !_sessionReady) {
state = state.copyWith(sessionReady: false);
}
});
}
// ── 읽기 · 맞추기 ─────────────────────────────────
Future<void> _initialize() async {
try {
final PushPreferenceValues values = await _prefs.load();
if (!ref.mounted) return;
_pendingUnregister = values.pendingUnregister;
// 풀지 못한 등록이 남았으면 지난 FID 는 믿지 않는다(다시 등록하고 새 소식을 기다린다)
_fid = values.pendingUnregister ? null : values.lastFid;
_serverRow = values.serverRegistered ? null : false;
state = state.copyWith(
enabled: values.enabled,
offerDismissed: values.offerDismissed,
);
await _readDevice();
} on Object {
// 읽지 못한 값은 처음 값으로 — 켤 때 다시 묻는다
}
if (!ref.mounted) return;
state = state.copyWith(loaded: true);
// 앱을 띄웠다 — 앞에 있으면 앞으로 온 것과 같다(알림창 지우기 · 봤음)
if (ref.read(appForegroundProvider)) {
unawaited(_notifications.clearNewMail());
if (_sessionReady) _seenPending = true;
}
_retryUnregisterIfSignedOut();
_requestReconcile();
}
/// 알림 권한 · 새 메일 채널을 읽는다 — 읽지 못한 권한은 한 번 거절한 것으로 본다(켤 때 다시 묻는다 — 창이 뜨지 않으면 그 결과가 진짜 상태다)
Future<void> _readDevice() async {
final PushPermission permission = await _readPermission();
final NotificationStatus status = await _notifications.status();
if (!ref.mounted) return;
state = state.copyWith(
permission: permission,
channelEnabled: status.channelEnabled,
sdkInt: status.sdkInt,
);
}
Future<PushPermission> _readPermission() async {
try {
return await _messaging.permission();
} on Object {
return state.permission ?? PushPermission.denied;
}
}
Future<PushPermission> _askPermission() async {
try {
return await _messaging.requestPermission();
} on Object {
return _readPermission();
}
}
/// 맞추기를 줄에 세운다 — 이미 기다리는 맞추기가 있으면 그것이 한다
void _requestReconcile() {
if (_reconcileQueued || !ref.mounted || !state.deviceSupported) return;
_reconcileQueued = true;
unawaited(
_serial(() async {
_reconcileQueued = false;
try {
await _reconcile();
} on Object {
// 다음 맞추기(앞으로 옴 · 등록 소식 · 다음 로그인)에 다시
}
}),
);
}
Future<void> _reconcile() async {
await _ready;
if (!ref.mounted || !_canReachServer) return;
final int epoch = _epoch;
if (pushWanted(state)) {
_startFcmRegistration();
final String? fid = _fid;
// FID 가 아직 없다 — 등록 소식(onRegistered)이 다시 맞춘다
if (fid == null) return;
if (_serverRow == true && _serverFid == fid) return;
await _put(fid, epoch: epoch, silent: true);
} else if (_serverRow != false) {
await _delete(epoch: epoch, silent: true);
}
}
/// 직원이 켰다 — FID 를 기다려(15초) 알림을 켠 채 올린다. 그사이 세션이 바뀌었으면 건너뛴다(켬 · 끔을 건드리지 않는다)
Future<_UserRegistration> _registerByUser(int epoch) async {
if (!_current(epoch) || !_canReachServer) return _UserRegistration.skipped;
_startFcmRegistration();
final String? fid = _fid ?? await _waitForFid();
if (!_current(epoch)) return _UserRegistration.skipped;
if (fid == null) {
// 요청을 보내지 못했다 — 인터셉터가 알리지 않으니 여기서 사전 문구로
_errors.show(ApiException.fromCode(kFallbackCode));
return _UserRegistration.failed;
}
if (_serverRow == true && _serverFid == fid) {
return _UserRegistration.registered;
}
if (await _put(fid, epoch: epoch, silent: false)) {
return _UserRegistration.registered;
}
return _current(epoch)
? _UserRegistration.failed
: _UserRegistration.skipped;
}
Future<bool> _put(
String fid, {
required int epoch,
required bool silent,
}) async {
try {
await _repository.put(fid: fid, appVersion: _appVersion, silent: silent);
} on DioException {
// 알리지 않는다(저절로 맞추기) — 다음 앞으로 옴 · 다음 등록 소식 · 다음 로그인에 다시
return false;
}
if (!_current(epoch)) return false;
_serverRow = true;
_serverFid = fid;
state = state.copyWith(registered: true);
unawaited(_prefs.setLastFid(fid));
unawaited(_prefs.setServerRegistered(true));
_flushSeen();
return true;
}
Future<bool> _delete({required int epoch, required bool silent}) async {
try {
await _repository.delete(silent: silent);
} on DioException {
return false;
}
if (!ref.mounted || epoch != _epoch) return false;
_serverRow = false;
_serverFid = null;
state = state.copyWith(registered: false);
unawaited(_prefs.setServerRegistered(false));
return true;
}
/// 서버와 맞출 수 있는가 — 세션 · 토큰 · 휴대폰 · 서버. 화면 상태(`sessionReady`)는 [pause] 의 다음 차례에야 내려가므로
/// 이 컨트롤러가 곧바로 아는 [_sessionReady] 도 본다(멈춘 바로 그 차례에 줄 선 맞추기가 제한된 세션으로 보내지 않게)
bool get _canReachServer =>
_sessionReady && pushReachable(state) && _store.hasSession;
/// 그 세션 그대로인가 — 늦은 결과를 버린다
bool _current(int epoch) => ref.mounted && epoch == _epoch && _sessionReady;
// ── FCM ───────────────────────────────────────
/// FCM 등록을 시작한다 — 이번 실행에서 한 번(실패하면 다음 맞추기에 다시). FID 는 등록 소식으로 온다.
/// 풀기가 줄에 섰거나 도는 중이면 그 뒤에 선다([_fcm] 줄 — 풀기가 [_fcmRegistered] · [_fcmRegistering] 을 먼저 내려 둔다)
void _startFcmRegistration() {
_fcmWanted = true;
if (_fcmRegistered || _fcmRegistering != null) return;
final int generation = _fcmGeneration;
final Future<void> registering = _fcm(() async {
await _messaging.register();
// 그사이 풀기를 바랐으면(로그아웃) 이 등록은 마친 것으로 치지 않는다 — 뒤에 선 풀기가 푼다
if (generation == _fcmGeneration) _fcmRegistered = true;
});
_fcmRegistering = registering;
unawaited(
registering
.then<void>(
(void _) {
// 풀기를 바라지 않은 채 끝났을 때만 — 풀지 못한 등록이 이 등록으로 이어졌다(같은 FID 의 PUT 이 서버의 옛 줄을 지운다)
if (generation == _fcmGeneration &&
_pendingUnregister &&
ref.mounted) {
_pendingUnregister = false;
unawaited(_prefs.setPendingUnregister(false));
}
},
onError: (Object _, StackTrace _) {
// 다음 맞추기(앞으로 옴 · 다음 로그인)에 다시 — 기다리던 켜기는 끝낸다.
// 그사이 풀기를 시작했으면 풀린 등록의 실패다 — 다음 세션이 기다리는 켜기는 그 세션의 등록을 기다린다
if (generation == _fcmGeneration) _completeFidWaiters(null);
},
)
.whenComplete(() {
if (identical(_fcmRegistering, registering)) {
_fcmRegistering = null;
}
}),
);
}
/// FCM 등록을 푼다 — 못 풀면 표시를 남겨 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시.
/// 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다 — 풀기가 실패하거나 오래 걸려도 다음 세션의 등록이 이 풀기 뒤에 줄을 선다
Future<void> _unregisterFcm() async {
_fcmGeneration++;
final int generation = _fcmGeneration;
_fcmRegistered = false;
_fcmRegistering = null;
_fcmUnregistering++;
_pendingUnregister = true;
unawaited(_prefs.setPendingUnregister(true));
try {
await _fcm(_messaging.unregister);
} on Object {
return;
} finally {
_fcmUnregistering--;
}
// 그사이 다시 등록하기 시작했거나(다른 직원의 로그인) 또 풀기를 시작했으면 그쪽이 표시를 정한다
if (!ref.mounted || _fcmWanted || generation != _fcmGeneration) return;
_pendingUnregister = false;
unawaited(_prefs.setPendingUnregister(false));
}
void _retryUnregisterIfSignedOut() {
if (!_pendingUnregister || _fcmWanted || _store.hasSession) return;
unawaited(_unregisterFcm());
}
void _onRegistered(String fid) {
// 풀었거나 아직 바라지 않는 등록 · 풀리는 중인 등록의 소식은 쓰지 않는다
if (!ref.mounted || !_fcmWanted || _fcmUnregistering > 0) return;
_fid = fid;
_completeFidWaiters(fid);
_requestReconcile();
}
Future<String?> _waitForFid() async {
final Completer<String?> waiter = Completer<String?>();
_fidWaiters.add(waiter);
try {
return await waiter.future.timeout(kPushRegisterWait);
} on TimeoutException {
return null;
} finally {
_fidWaiters.remove(waiter);
}
}
void _completeFidWaiters(String? fid) {
for (final Completer<String?> waiter in List<Completer<String?>>.of(
_fidWaiters,
)) {
if (!waiter.isCompleted) waiter.complete(fid);
}
}
// ── 앞으로 옴 · 받은 알림 · 봤음 ───────────────────
void _cameToForeground() {
if (!ref.mounted || !state.deviceSupported) return;
unawaited(_notifications.clearNewMail());
if (_sessionReady) _seenPending = true;
unawaited(
_serial(() async {
await _ready;
await _readDevice();
}).then<void>((void _) {
if (!ref.mounted) return;
_requestReconcile();
_flushSeen();
}, onError: (Object _, StackTrace _) {}),
);
_retryUnregisterIfSignedOut();
}
void _onForegroundMessage(PushMessage message) {
if (!ref.mounted) return;
ref.read(pushEventsProvider.notifier).add(message, opened: false);
if (message.isNewMail && _sessionReady) {
_seenPending = true;
_flushSeen();
}
}
void _onOpened(PushMessage message) {
if (!ref.mounted) return;
ref.read(pushEventsProvider.notifier).add(message, opened: true);
}
/// 앱이 뒤로 갔다 — 간격 끝을 기다리던 봤음은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다)
void _wentToBackground() {
final Timer? timer = _seenTimer;
if (!ref.mounted || timer == null) return;
timer.cancel();
_seenTimer = null;
if (_seenPending && _canSendSeen) _sendSeen(_clock());
}
/// 봤음을 보낼 수 있는가 — 이 세션의 서버 줄이 있을 때만(없으면 등록 뒤에)
bool get _canSendSeen =>
_sessionReady && _serverRow == true && _store.hasSession;
/// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번
void _flushSeen() {
if (!_seenPending || !ref.mounted || _seenTimer != null) return;
// 뒤에 있으면 보내지 않는다 — 앞으로 올 때 다시 빚진다
if (!_canSendSeen || !ref.read(appForegroundProvider)) return;
final DateTime now = _clock();
final DateTime? last = _lastSeenAt;
final Duration? since = last == null ? null : now.difference(last);
if (since == null || since.isNegative || since >= kPushSeenInterval) {
_sendSeen(now);
return;
}
_seenTimer = Timer(kPushSeenInterval - since, () {
_seenTimer = null;
_flushSeen();
});
}
void _sendSeen(DateTime now) {
_seenPending = false;
_lastSeenAt = now;
unawaited(
_repository.markSeen().then<void>(
(void _) {},
// 알리지 않는다 — 다음 앞으로 옴에 다시
onError: (Object _, StackTrace _) {},
),
);
}
void _cancelSeen() {
_seenPending = false;
_seenTimer?.cancel();
_seenTimer = null;
}
// ── 차례 ──────────────────────────────────────
Future<T> _serial<T>(Future<T> Function() operation) {
final Future<T> result = _queue.then((void _) => operation());
_queue = result.then<void>((T _) {}, onError: (Object _, StackTrace _) {});
return result;
}
Future<T> _fcm<T>(Future<T> Function() operation) {
final Future<T> result = _fcmQueue.then((void _) => operation());
_fcmQueue = result.then<void>(
(T _) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
static PushServer _serverOf(bool? available) => switch (available) {
true => PushServer.available,
false => PushServer.unavailable,
null => PushServer.unknown,
};
void _dispose() {
for (final StreamSubscription<Object?> subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_subscriptions.clear();
_seenTimer?.cancel();
_seenTimer = null;
_completeFidWaiters(null);
}
}
/// 직원이 켠 등록의 결과 — 실패면 이 휴대폰의 값을 끔으로, 건너뜀(그사이 세션이 바뀜)이면 켬 · 끔을 건드리지 않는다
enum _UserRegistration { registered, failed, skipped }
+60
View File
@@ -0,0 +1,60 @@
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 앱이 받은 알림 소식 — 앱이 앞에 있을 때 온 알림(알림창에 띄우지 않는다)과 직원이 누른 알림
@immutable
class PushEvent {
const PushEvent({
required this.seq,
required this.message,
required this.opened,
});
/// 소식마다 하나씩 오른다
final int seq;
final PushMessage message;
/// 직원이 누른 알림 — 거짓이면 앞에 있을 때 온 알림
final bool opened;
@override
bool operator ==(Object other) =>
other is PushEvent &&
other.seq == seq &&
other.message == message &&
other.opened == opened;
@override
int get hashCode => Object.hash(seq, message, opened);
}
/// 받은 알림 소식 — 새 메일 알림 컨트롤러가 올리고, 메일 모듈(새 메일 확인을 바로) · 라우터(누른 알림 — `core/router/push_navigation.dart`)가 듣는다.
/// core/push 는 라우터 · 모듈을 모른다 — 듣는 쪽이 고른다.
final NotifierProvider<PushEvents, PushEvent?> pushEventsProvider =
NotifierProvider<PushEvents, PushEvent?>(PushEvents.new);
class PushEvents extends Notifier<PushEvent?> {
PushMessage? _pendingOpen;
@override
PushEvent? build() => null;
/// 소식을 올린다 — 누른 알림은 라우터가 가져갈 때까지 값으로 든다(앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않게)
void add(PushMessage message, {required bool opened}) {
if (opened) _pendingOpen = message;
state = PushEvent(
seq: (state?.seq ?? 0) + 1,
message: message,
opened: opened,
);
}
/// 아직 가져가지 않은 누른 알림을 가져간다 — 한 번만(상태는 바꾸지 않는다 — 다른 프로바이더를 만드는 중에도 부를 수 있게)
PushMessage? takeOpened() {
final PushMessage? opened = _pendingOpen;
_pendingOpen = null;
return opened;
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'dart:async';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/push/push_target.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 알림 권한 — 플랫폼마다 다른 값을 넷으로 묶는다(안드로이드는 `firebase_push_messaging.dart` 의 `pushPermissionOf`).
enum PushPermission {
/// 알림을 보여도 된다
granted,
/// 아직 묻지 않았다 — [알림 켜기]가 시스템 창을 띄운다(안드로이드 13 이상)
notAsked,
/// 한 번 거절했다 — 다시 물을 수 있다(안드로이드 13 이상)
denied,
/// 휴대폰 설정에서만 켤 수 있다 — 두 번 거절했거나, 안드로이드 12 이하에서 꺼져 있다
blocked,
}
/// 알림 종류 — 서버가 data 의 `kind` 칸에 싣는다(짝: 서버 `modules/push/push.constants.ts`)
abstract final class PushKinds {
/// "새 메일 N통"
static const String newMail = 'new-mail';
/// 세션이 끝나 알림이 멈췄다 — 저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 등(보내는 때는 서버 `SESSION_ENDED_NOTICE` ·
/// 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘"). 같은 tag 로 "새 메일 N통"을 바꿔 쓴다.
/// 앱은 새 메일로 보지 않고(확인 · 봤음 없음), 누르면 다른 알림처럼 메일 탭으로 간다 — 세션이 없으니 첫 요청에서 로그인 화면이다
static const String sessionEnded = 'session-ended';
}
/// 받은 알림 — 데이터의 모듈 · 종류만 읽는다(보낸 사람 · 제목 같은 내용은 싣지 않는다 — push_target.dart)
@immutable
class PushMessage {
const PushMessage({this.target, this.kind});
/// 알림 데이터(`{ module: 'mail', kind: 'new-mail' }`)를 읽는다 — 모르는 모듈이면 [target] 이 null
factory PushMessage.fromData(Map<String, Object?> data) {
final Object? kind = data[kindKey];
return PushMessage(
target: PushTarget.fromData(data),
kind: kind is String && kind.isNotEmpty ? kind : null,
);
}
/// 알림 데이터의 종류 칸 — 서버와 짝이다
static const String kindKey = 'kind';
/// 갈 곳 — 모르는 모듈이면 null(누르면 홈)
final PushTarget? target;
/// 알림 종류([PushKinds]) — 없으면 null
final String? kind;
/// 메일 모듈의 새 메일 소식인가 — 종류가 없는 메일 알림도 새 메일로 본다
bool get isNewMail =>
target?.module == AppModuleId.mail &&
(kind == null || kind == PushKinds.newMail);
@override
bool operator ==(Object other) =>
other is PushMessage &&
other.kind == kind &&
other.target?.module == target?.module &&
other.target?.itemId == target?.itemId;
@override
int get hashCode => Object.hash(kind, target?.module, target?.itemId);
}
/// 휴대폰의 알림 받기(FCM) — 포트다. 앱 코드는 이것만 알고, firebase_messaging 은 `firebase_push_messaging.dart` 만 import 한다.
///
/// 기기 주소는 FID(Firebase 설치 ID — [register] 뒤 [onRegistered] 로 온다)다 — FCM 토큰 API 는 deprecated 라 쓰지 않는다.
/// 알림은 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다(앱 코드는 돌지 않는다). 앱이 앞에 있으면 띄우지 않고 [onForegroundMessage] 로만 온다.
abstract interface class PushMessaging {
/// 이 휴대폰에서 받을 수 있는가 — Firebase 를 띄웠다(설정 파일 없이 빌드했거나 Google Play 서비스가 없으면 거짓)
bool get isAvailable;
/// 지금 알림 권한
Future<PushPermission> permission();
/// 알림 권한을 묻는다 — 안드로이드 13 이상에서 묻지 않았거나 한 번 거절했으면 시스템 창을 띄운다. 직원이 [알림 켜기]를 누른 뒤에만 부른다
Future<PushPermission> requestPermission();
/// FCM 에 이 앱을 등록한다 — 끝나면 [onRegistered] 로 FID 가 온다(값을 돌려주지 않는다)
Future<void> register();
/// FCM 등록을 푼다 — 그 FID 로 보낸 알림은 오지 않는다
Future<void> unregister();
/// 등록된 FID — 등록할 때마다 · 바뀔 때마다 온다. **듣기 전에 온 값은 사라진다** — [register] 전에 듣는다
Stream<String> get onRegistered;
/// 앱이 앞에 있을 때 온 알림 — 알림창에 띄우지 않는다
Stream<PushMessage> get onForegroundMessage;
/// 앱이 뒤에 있을 때 누른 알림
Stream<PushMessage> get onOpened;
/// 꺼져 있던 앱을 띄운 알림 — 한 번만 온다(다시 부르면 null)
Future<PushMessage?> initialOpen();
}
/// 받을 수 없는 휴대폰 — 아무것도 하지 않는다(main 이 Firebase 를 띄우지 못했을 때 · 시험의 기본값)
class UnavailablePushMessaging implements PushMessaging {
const UnavailablePushMessaging();
@override
bool get isAvailable => false;
@override
Future<PushPermission> permission() async => PushPermission.blocked;
@override
Future<PushPermission> requestPermission() async => PushPermission.blocked;
@override
Future<void> register() async {}
@override
Future<void> unregister() async {}
@override
Stream<String> get onRegistered => const Stream<String>.empty();
@override
Stream<PushMessage> get onForegroundMessage =>
const Stream<PushMessage>.empty();
@override
Stream<PushMessage> get onOpened => const Stream<PushMessage>.empty();
@override
Future<PushMessage?> initialOpen() async => null;
}
/// 알림 받기 — main 이 Firebase 를 띄웠으면 `FirebasePushMessaging` 으로 덮어쓴다. 시험은 가짜로
final Provider<PushMessaging> pushMessagingProvider = Provider<PushMessaging>(
(Ref ref) => const UnavailablePushMessaging(),
);
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 이 휴대폰의 새 메일 알림 값 — 참 · 거짓과 FID 하나만 둔다(토큰 · 주소는 두지 않는다 — 보안 규칙 "토큰 저장")
@immutable
class PushPreferenceValues {
const PushPreferenceValues({
this.enabled = true,
this.offerDismissed = false,
this.pendingUnregister = false,
this.lastFid,
this.serverRegistered = false,
});
/// 처음 값 — 켜짐(이 휴대폰에서 켜고 끈다. 직원이 바뀌어도 그대로)
static const PushPreferenceValues initial = PushPreferenceValues();
/// 새 메일 알림을 켰다 — 처음은 참
final bool enabled;
/// 받은편지함 위 권하기 줄을 닫았다 — 다시 보이지 않는다
final bool offerDismissed;
/// 로그아웃 · 세션 끝에 FCM 등록을 풀지 못했다 — 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시 푼다
final bool pendingUnregister;
/// 마지막으로 서버에 올린 FID — FCM 등록이 그대로인 동안만(풀 때 지운다). 다음 실행에서 등록 소식을 기다리지 않고 바로 올린다
final String? lastFid;
/// 지금 세션의 서버 줄이 있을 수 있다 — 꺼진 채 앱을 띄울 때마다 지우기(DELETE)를 보내지 않게
final bool serverRegistered;
@override
bool operator ==(Object other) =>
other is PushPreferenceValues &&
other.enabled == enabled &&
other.offerDismissed == offerDismissed &&
other.pendingUnregister == pendingUnregister &&
other.lastFid == lastFid &&
other.serverRegistered == serverRegistered;
@override
int get hashCode => Object.hash(
enabled,
offerDismissed,
pendingUnregister,
lastFid,
serverRegistered,
);
}
/// 새 메일 알림 값을 두는 곳 — 포트다(앱은 `shared_preferences`, 시험은 메모리).
/// 읽기 · 쓰기가 실패해도 던지지 않는다 — 읽지 못하면 처음 값, 쓰지 못하면 이번 실행의 메모리 값으로 이어 간다
abstract interface class PushPreferences {
Future<PushPreferenceValues> load();
Future<void> setEnabled(bool value);
Future<void> setOfferDismissed(bool value);
Future<void> setPendingUnregister(bool value);
Future<void> setLastFid(String? fid);
Future<void> setServerRegistered(bool value);
}
/// `shared_preferences` 구현 — 앱을 지우면 함께 지워지고, 백업을 껐으니(AndroidManifest) 다른 기기로 옮겨지지 않는다
class PrefsPushPreferences implements PushPreferences {
PrefsPushPreferences([SharedPreferencesAsync? prefs]) : _given = prefs;
// 저장 키 — 바꾸면 이미 깐 앱의 설정이 처음 값으로 돌아간다
static const String enabledKey = 'push_enabled';
static const String offerDismissedKey = 'push_offer_dismissed';
static const String pendingUnregisterKey = 'push_pending_unregister';
static const String lastFidKey = 'push_last_fid';
static const String serverRegisteredKey = 'push_server_registered';
final SharedPreferencesAsync? _given;
SharedPreferencesAsync? _prefs;
// 처음 쓸 때 만든다 — 플러그인이 없는 곳(시험)에서 만들면 던진다
SharedPreferencesAsync get _store =>
_prefs ??= _given ?? SharedPreferencesAsync();
@override
Future<PushPreferenceValues> load() async {
try {
final SharedPreferencesAsync prefs = _store;
return PushPreferenceValues(
enabled: await prefs.getBool(enabledKey) ?? true,
offerDismissed: await prefs.getBool(offerDismissedKey) ?? false,
pendingUnregister: await prefs.getBool(pendingUnregisterKey) ?? false,
lastFid: await prefs.getString(lastFidKey),
serverRegistered: await prefs.getBool(serverRegisteredKey) ?? false,
);
} on Object {
return PushPreferenceValues.initial;
}
}
@override
Future<void> setEnabled(bool value) => _write(
(SharedPreferencesAsync prefs) => prefs.setBool(enabledKey, value),
);
@override
Future<void> setOfferDismissed(bool value) => _write(
(SharedPreferencesAsync prefs) => prefs.setBool(offerDismissedKey, value),
);
@override
Future<void> setPendingUnregister(bool value) => _write(
(SharedPreferencesAsync prefs) =>
prefs.setBool(pendingUnregisterKey, value),
);
@override
Future<void> setLastFid(String? fid) => _write(
(SharedPreferencesAsync prefs) => fid == null
? prefs.remove(lastFidKey)
: prefs.setString(lastFidKey, fid),
);
@override
Future<void> setServerRegistered(bool value) => _write(
(SharedPreferencesAsync prefs) => prefs.setBool(serverRegisteredKey, value),
);
Future<void> _write(
Future<void> Function(SharedPreferencesAsync prefs) write,
) async {
try {
await write(_store);
} on Object {
// 쓰지 못했다 — 이번 실행은 메모리 값으로 이어 간다
}
}
}
/// 새 메일 알림 값을 두는 곳 — 시험은 메모리로 덮어쓴다
final Provider<PushPreferences> pushPreferencesProvider =
Provider<PushPreferences>((Ref ref) => PrefsPushPreferences());
+74 -18
View File
@@ -1,35 +1,91 @@
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다).
/// 새 메일 알림의 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 등록을 쓴다).
///
/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와
/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다:
/// - 로그인을 마치면(제한 없음) [register]
/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다.
/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다
/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다).
/// 세션 노티파이어가 부른다(구현은 `push_controller.dart` 의 [PushController]):
/// - 메일을 쓸 수 있는 세션이 되면(제한 없는 로그인 · 되살리기 · 제한이 풀림) [register]
/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) · 세션이 끝나면(`AUTH_002`) [unregister]
/// - 세션에 제한이 새로 생기면(갱신 응답 — 비밀번호 만료 · 2단계 기한) [pause]
abstract interface class PushRegistrar {
/// 메일을 쓸 수 있는 세션이 됐다 — 이 휴대폰이 켬이고 알림이 허락됐으면 서버에 등록한다(그 세션에서 처음 한 번)
Future<void> register();
/// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다)
/// 세션이 끝났다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다.
/// 서버는 부르지 않는다(인증 토큰이 이미 없다 — 서버 줄은 logout · 세션 끝이 함께 지운다). 짧은 한도 안에서만 기다리고 실패는 삼킨다
Future<void> unregister();
/// 세션에 제한이 생겼다 — 제한이 풀릴 때까지([register]) 서버를 부르지 않는다(서버도 제한된 세션에는 보내지 않는다)
void pause();
}
/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다
/// 로그아웃 뒤 FCM 등록 풀기를 기다리는 최대 시간 — 넘으면 그만 기다린다(풀기는 뒤에서 이어지고, 못 풀면 다음 실행에 다시)
const Duration kPushUnregisterTimeout = Duration(seconds: 5);
/// 지금의 구현 — 아무것도 하지 않는다
class NoopPushRegistrar implements PushRegistrar {
const NoopPushRegistrar();
/// 서버에 알리는 플랫폼 — iOS 를 붙일 때 `ios` 를 더한다(계약 "앱 새 메일 알림")
const String kPushPlatform = 'android';
@override
Future<void> register() async {}
/// 판을 읽지 못한 기기(거의 없다)가 보내는 앱 판 — 서버 칸의 모양(`숫자.숫자.숫자`)에 맞춘 값
const String kUnknownAppVersion = '0.0.0';
@override
Future<void> unregister() async {}
/// 이 세션의 새 메일 알림 등록 — 계약 1절 "앱 새 메일 알림 · 앱 설정"
///
/// - `PUT /api/push-registrations/current` `{ fid, platform, appVersion }` → 200(응답은 쓰지 않는다). 서버는 세션(sid)마다 줄 하나를 두고
/// 같은 FID 의 다른 줄(다른 세션 · 다른 직원)을 지운다.
/// - `DELETE /api/push-registrations/current` → 204(없어도 204)
/// - `DELETE /api/push-registrations/current/unseen` → 204 — "봤음"(서버가 N 을 처음부터 센다)
///
/// 저절로 하는 맞추기는 알림을 끄고([silent]), 직원이 켜고 끌 때만 알린다(인터셉터의 사전 문구). FID 는 로그에 남기지 않는다.
class PushRegistrationRepository {
const PushRegistrationRepository(this._dio);
final Dio _dio;
/// 등록한다 — 실패는 `DioException`
Future<void> put({
required String fid,
required String appVersion,
required bool silent,
}) async {
await _dio.put<Object?>(
ApiPaths.pushRegistration,
data: <String, Object?>{
'fid': fid,
'platform': kPushPlatform,
'appVersion': appVersion,
},
options: silent ? ApiOptions.silent() : null,
);
}
/// 등록을 지운다 — 실패는 `DioException`
Future<void> delete({required bool silent}) async {
await _dio.delete<Object?>(
ApiPaths.pushRegistration,
options: silent ? ApiOptions.silent() : null,
);
}
/// "봤음" — 알리지 않는다. 실패는 `DioException`
Future<void> markSeen() async {
await _dio.delete<Object?>(
ApiPaths.pushRegistrationUnseen,
options: ApiOptions.silent(),
);
}
}
/// 푸시 기기 등록
/// 새 메일 알림 등록 API
final Provider<PushRegistrationRepository> pushRegistrationRepositoryProvider =
Provider<PushRegistrationRepository>(
(Ref ref) => PushRegistrationRepository(ref.watch(dioProvider)),
);
/// 세션 노티파이어가 부르는 기기 등록 — 새 메일 알림 컨트롤러다(시험은 기록하는 가짜로 덮어쓴다)
final Provider<PushRegistrar> pushRegistrarProvider = Provider<PushRegistrar>(
(Ref ref) => const NoopPushRegistrar(),
(Ref ref) => ref.watch(pushControllerProvider.notifier),
);
+250
View File
@@ -0,0 +1,250 @@
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:flutter/foundation.dart';
/// 안드로이드 13(API 33)부터 알림 권한을 묻는다 — 권하기 줄은 그 판부터 보인다
const int kPushOfferMinSdk = 33;
/// 서버가 새 메일 알림을 보낼 수 있는가 — `GET /api/app-config` 의 `pushAvailable`
enum PushServer {
/// 아직 모른다 · 받지 못했다 — 쓸 수 있는 것으로 본다(막지 않는다)
unknown,
available,
/// 보낼 수 없다(FCM 키가 없다 · 그 경로가 없는 옛 서버)
unavailable,
}
/// 직원이 켜고 끄는 중
enum PushChange { turningOn, turningOff }
/// 새 메일 알림 상태 — 설정 › 새 메일 알림 줄과 받은편지함 위 권하기 줄이 본다([pushRowStatusOf] · [showsPushOffer])
@immutable
class PushState {
const PushState({
this.loaded = false,
this.deviceSupported = false,
this.server = PushServer.unknown,
this.sessionReady = false,
this.enabled = true,
this.permission,
this.channelEnabled = true,
this.sdkInt,
this.registered = false,
this.change,
this.offerDismissed = false,
});
/// 이 휴대폰의 값(켬 · 권하기 줄 닫음)과 알림 권한을 읽었다
final bool loaded;
/// 이 휴대폰에서 받을 수 있다 — Firebase 를 띄웠다
final bool deviceSupported;
final PushServer server;
/// 메일을 쓸 수 있는 세션이다(제한 없는 로그인) — 세션 노티파이어의 register · unregister · pause
final bool sessionReady;
/// 이 휴대폰에서 켰다 — 처음은 켜짐(직원이 바뀌어도 그대로)
final bool enabled;
/// 알림 권한 — 아직 읽지 않았으면 null
final PushPermission? permission;
/// 새 메일 채널이 켜져 있다(휴대폰 설정에서 채널만 끌 수 있다)
final bool channelEnabled;
/// 안드로이드 판(API) — 모르면 null
final int? sdkInt;
/// 이 세션의 서버 등록이 있다(이번 실행에서 확인했다)
final bool registered;
/// 직원이 켜거나 끄는 중
final PushChange? change;
/// 받은편지함 위 권하기 줄을 닫았다
final bool offerDismissed;
PushState copyWith({
bool? loaded,
bool? deviceSupported,
PushServer? server,
bool? sessionReady,
bool? enabled,
PushPermission? permission,
bool? channelEnabled,
int? sdkInt,
bool? registered,
PushChange? change,
bool clearChange = false,
bool? offerDismissed,
}) {
return PushState(
loaded: loaded ?? this.loaded,
deviceSupported: deviceSupported ?? this.deviceSupported,
server: server ?? this.server,
sessionReady: sessionReady ?? this.sessionReady,
enabled: enabled ?? this.enabled,
permission: permission ?? this.permission,
channelEnabled: channelEnabled ?? this.channelEnabled,
sdkInt: sdkInt ?? this.sdkInt,
registered: registered ?? this.registered,
change: clearChange ? null : (change ?? this.change),
offerDismissed: offerDismissed ?? this.offerDismissed,
);
}
@override
bool operator ==(Object other) =>
other is PushState &&
other.loaded == loaded &&
other.deviceSupported == deviceSupported &&
other.server == server &&
other.sessionReady == sessionReady &&
other.enabled == enabled &&
other.permission == permission &&
other.channelEnabled == channelEnabled &&
other.sdkInt == sdkInt &&
other.registered == registered &&
other.change == change &&
other.offerDismissed == offerDismissed;
@override
int get hashCode => Object.hash(
loaded,
deviceSupported,
server,
sessionReady,
enabled,
permission,
channelEnabled,
sdkInt,
registered,
change,
offerDismissed,
);
}
/// 이 휴대폰이 알림을 받고 싶은가 — 켬 ∧ 권한 ∧ 새 메일 채널
bool pushWanted(PushState state) =>
state.enabled &&
state.permission == PushPermission.granted &&
state.channelEnabled;
/// 서버와 맞출 수 있는가 — 값을 읽었고 ∧ 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있고(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션
bool pushReachable(PushState state) =>
state.loaded &&
state.deviceSupported &&
state.server != PushServer.unavailable &&
state.sessionReady;
/// 설정 › 새 메일 알림 줄의 모습 — 보드 `App1Push` ④ · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘" 의 표
enum PushRowStatus {
/// 아직 읽는 중 — 스위치를 누를 수 없다
loading,
/// 켜짐
on,
/// 꺼짐 — 직원이 껐다
off,
/// 꺼짐 · 허락 전 — 켜면 휴대폰이 허용할지 묻는다(안드로이드 13 이상, 묻지 않음 · 한 번 거절)
notAsked,
/// 휴대폰 설정에서 이 앱의 알림이 막혔다 — 스위치는 누를 수 없고 [설정 열기]
blocked,
/// 휴대폰 설정에서 새 메일 채널만 꺼졌다 — 스위치는 누를 수 없고 [설정 열기]
channelOff,
/// 지금은 쓸 수 없다(서버가 보낼 수 없다) — 스위치는 누를 수 없다
unavailable,
/// 이 휴대폰에서는 받을 수 없다(Firebase 를 띄우지 못함 · Google Play 서비스 없음) — 스위치는 누를 수 없다
unsupported,
/// 켜는 중 — 스위치는 켬 · 바쁨
turningOn,
/// 끄는 중 — 스위치는 끔 · 바쁨
turningOff,
}
/// 설정 줄의 모습 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 읽는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔
PushRowStatus pushRowStatusOf(PushState state) {
if (!state.loaded) return PushRowStatus.loading;
if (!state.deviceSupported) return PushRowStatus.unsupported;
if (state.server == PushServer.unavailable) return PushRowStatus.unavailable;
switch (state.change) {
case PushChange.turningOn:
return PushRowStatus.turningOn;
case PushChange.turningOff:
return PushRowStatus.turningOff;
case null:
break;
}
final PushPermission? permission = state.permission;
if (permission == null) return PushRowStatus.loading;
switch (permission) {
case PushPermission.blocked:
return PushRowStatus.blocked;
case PushPermission.notAsked:
case PushPermission.denied:
return PushRowStatus.notAsked;
case PushPermission.granted:
break;
}
if (!state.channelEnabled) return PushRowStatus.channelOff;
return state.enabled ? PushRowStatus.on : PushRowStatus.off;
}
/// 받은편지함 위 권하기 줄을 보이는가 — 안드로이드 13 이상 · 아직 묻지 않음 · 켬 · 서버가 보낼 수 있음 · 받을 수 있는 휴대폰 ·
/// 메일을 쓸 수 있는 세션 · 닫은 적 없음 · 켜는 중이 아님
bool showsPushOffer(PushState state) =>
state.loaded &&
state.deviceSupported &&
state.server != PushServer.unavailable &&
state.sessionReady &&
state.enabled &&
!state.offerDismissed &&
state.change == null &&
state.permission == PushPermission.notAsked &&
(state.sdkInt ?? 0) >= kPushOfferMinSdk;
/// [알림 켜기] · 설정 스위치를 켠 결과 — 화면이 글을 고른다(보드 `App1Push` ③ · ④)
enum PushTurnOnResult {
/// 켰다(서버 등록까지 끝났다)
on,
/// 시스템 창에서 [허용 안 함]
denied,
/// 휴대폰 설정에서만 켤 수 있다(창이 뜨지 않았다)
blocked,
/// 새 메일 채널만 꺼져 있다
channelOff,
/// 지금 쓸 수 없다 · 받을 수 없는 휴대폰 · 세션이 없다
unavailable,
/// 서버 등록이 실패했다 — 그 코드의 사전 문구를 이미 알렸다(이 휴대폰의 값을 끔으로 두어 스위치가 꺼진 채로 돌아간다 — 저절로 다시 하지 않는다)
failed,
/// 이미 켜거나 끄는 중
busy,
}
/// 설정 스위치를 끈 결과
enum PushTurnOffResult {
/// 껐다
off,
/// 서버에서 지우지 못했다 — 그 코드의 사전 문구를 이미 알렸다(스위치는 되돌린다)
failed,
/// 이미 켜거나 끄는 중
busy,
}
+3 -2
View File
@@ -1,14 +1,15 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다.
/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과(있으면) 항목 id 만** 싣는다.
///
/// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림").
/// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다.
/// 새 메일 알림의 데이터는 `{ module: 'mail', kind: 'new-mail' }` 이다(id 없음 — 늘 받은편지함. 종류는 `push_messaging.dart` 의 `PushMessage`).
class PushTarget {
const PushTarget({required this.module, this.itemId});
/// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null.
/// 알림 데이터(`{ module: 'mail', … }` — 항목이 있는 알림은 `id: '…'` 도)를 읽는다. 모듈 이름을 모르면 null.
static PushTarget? fromData(Map<String, Object?> data) {
final Object? name = data[moduleKey];
final AppModuleId? module = AppModuleId.fromName(
+47
View File
@@ -0,0 +1,47 @@
/// 새 메일 알림의 화면 글 — 받은편지함 위 권하기 줄(메일 모듈)과 설정 › 새 메일 알림 줄(설정 모듈)이 함께 쓴다.
///
/// 앱 보드 `App1Push`(2026-11-07 확정)의 글 그대로다 — 글자 하나까지(디자인 README 「화면 문구 — 사용자에게 말하듯」).
/// 휴대폰 설정에 보이는 채널 이름 · 설명은 안드로이드 `res/values/strings.xml` 에 있다(보드 ⑫).
abstract final class PushTexts {
// ── ① 받은편지함 · 권하기 줄 ──
/// 줄의 영역 이름(화면 낭독기)
static const String offerRegion = '새 메일 알림 안내';
static const String offerMessage =
'새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.';
static const String offerEnable = '알림 켜기';
/// [✕]의 이름(화면 낭독기)
static const String offerDismiss = '닫기';
// ── ③ 휴대폰이 묻는 창에서 고른 뒤 ──
static const String enabled = '새 메일 알림을 켰습니다.';
static const String deniedLater = '나중에 [설정] 탭에서 새 메일 알림을 켤 수 있습니다.';
/// 창이 뜨지 않을 만큼 이미 막혀 있을 때 — 알림에 [설정 열기]
static const String blockedNotice = '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다.';
/// 새 메일 알림 카테고리만 꺼져 있을 때 — 알림에 [설정 열기](보드 ④ 의 같은 상태 글의 앞 문장)
static const String channelOffNotice = '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다.';
// ── ④ 설정 › 새 메일 알림 줄 ──
static const String rowLabel = '새 메일 알림';
static const String rowDescription = '새 메일이 오면 휴대폰 알림으로 알려 드립니다.';
static const String rowNotAsked = '켜면 휴대폰이 알림을 허용할지 묻습니다. 그때 [허용]을 눌러 주세요.';
static const String rowBlocked =
'휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다. [설정 열기]를 눌러 알림을 켜 주세요.';
static const String rowChannelOff =
'휴대폰 설정에서 새 메일 알림이 꺼져 있습니다. [설정 열기]를 눌러 켜 주세요.';
static const String rowUnavailable =
'지금은 새 메일 알림을 쓸 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.';
static const String rowUnsupported =
'이 휴대폰에서는 새 메일 알림을 받을 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.';
/// 켜는 중 · 끄는 중(화면 낭독기가 읽는다)
static const String turningOn = '켜는 중';
static const String turningOff = '끄는 중';
// ── [설정 열기] ──
static const String openSettings = '설정 열기';
static const String openSettingsFailed =
'휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.';
}
+28 -13
View File
@@ -1,10 +1,13 @@
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
import 'package:arasystem_app/core/constants/screen_titles.dart';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/core/router/app_routes.dart';
import 'package:arasystem_app/core/router/app_shell.dart';
import 'package:arasystem_app/core/router/module_registry.dart';
import 'package:arasystem_app/core/router/push_navigation.dart';
import 'package:arasystem_app/core/router/session_redirect.dart';
import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart';
@@ -22,15 +25,17 @@ final Provider<List<AppModule>> appModulesProvider = Provider<List<AppModule>>(
(Ref ref) => kAppModules,
);
/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다.
/// 앱 라우터 — 세션 · 업데이트 여부(앱 설정의 최소 판)가 바뀔 때마다 redirect 를 다시 돌린다.
///
/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다.
/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)과 업데이트 화면(`/update`)은 탭 막대 밖(root navigator)이다.
/// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다).
/// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다.
/// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException).
/// - 누른 알림은 [connectPushNavigation] 이 옮긴다(새 메일 알림 컨트롤러도 그때 띄운다).
///
/// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다.
/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음).
/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). 업데이트 화면은 보드 `App1Push` ⑩ 의 화면이다
/// (`features/app_update/` — 모듈이 아니다).
final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
final List<AppModule> modules = ref.watch(appModulesProvider);
final List<AppModule> enabled = enabledAppModules(modules);
@@ -42,22 +47,28 @@ final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
settingsSections: moduleSettingsSections(moduleRoutes),
);
final _SessionChanges sessionChanges = _SessionChanges();
ref.listen<AsyncValue<SessionState>>(
sessionProvider,
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
sessionChanges.notify(),
);
final _RouteChanges routeChanges = _RouteChanges();
ref
..listen<AsyncValue<SessionState>>(
sessionProvider,
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
routeChanges.notify(),
)
..listen<bool>(
appUpdateRequiredProvider,
(bool? previous, bool next) => routeChanges.notify(),
);
final GoRouter router = GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.start,
refreshListenable: sessionChanges,
refreshListenable: routeChanges,
redirect: (BuildContext context, GoRouterState state) =>
resolveSessionRedirect(
ref.read(sessionProvider),
state.uri.path,
modules: modules,
updateRequired: ref.read(appUpdateRequiredProvider),
),
// 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로
onException: (BuildContext context, GoRouterState state, GoRouter router) =>
@@ -69,6 +80,8 @@ final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
_loginRoute(AppRoutes.passwordChange, const PasswordChangePage()),
_loginRoute(AppRoutes.twoFactorSetup, const TwoFactorSetupPage()),
_loginRoute(AppRoutes.restricted, const RestrictedLoginPage()),
// 업데이트 — 뒤로 가기는 앱을 닫는다(go 로만 오니 쌓인 화면이 없다)
_loginRoute(AppRoutes.update, const AppUpdatePage()),
StatefulShellRoute.indexedStack(
builder:
(
@@ -84,9 +97,11 @@ final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
],
);
connectPushNavigation(ref, router, modules: modules);
ref.onDispose(() {
router.dispose();
sessionChanges.dispose();
routeChanges.dispose();
});
return router;
});
@@ -107,7 +122,7 @@ GoRoute _loginRoute(String path, Widget page) {
);
}
/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable)
class _SessionChanges extends ChangeNotifier {
/// 세션 · 업데이트 여부가 바뀐 것을 라우터에 알린다 (refreshListenable)
class _RouteChanges extends ChangeNotifier {
void notify() => notifyListeners();
}
+3
View File
@@ -20,4 +20,7 @@ abstract final class AppRoutes {
/// 제한된 세션 — 앱이 모르는 제한
static const String restricted = '/login/restricted';
/// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도)
static const String update = '/update';
}
@@ -0,0 +1,46 @@
import 'dart:async';
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/push/push_controller.dart';
import 'package:arasystem_app/core/push/push_events.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_state.dart';
import 'package:arasystem_app/core/push/push_target.dart';
import 'package:arasystem_app/core/router/module_registry.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 누른 알림을 라우터로 옮긴다 — 라우터 프로바이더가 만들 때 한 번 건다(core/push 는 라우터를 모른다).
///
/// - 누른 알림은 [PushEvents] 가 값으로 들고 있다가 여기서 가져간다 — 앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않는다.
/// - 갈 곳은 [locationForPush] — 새 메일 알림은 늘 메일 탭 받은편지함이다(메일 탭의 다른 메일함 · 검색 · 읽기 · 쓰기는 닫힌다.
/// 쓰던 메일은 쓰기 화면이 내려가며 임시보관함에 마저 저장한다).
/// - 로그아웃 상태 · 되살리는 중 · 업데이트해야 하면 redirect 가 그 화면으로 돌려보낸다 — 기다려 둔 갈 곳은 두지 않는다
/// (로그인 뒤 첫 화면이 받은편지함이다. id 가 있는 알림이 생기면 그때 더한다).
/// - 다른 직원이 로그인해 있어도 그 직원의 받은편지함을 연다 — 알림에 메일 · 직원을 싣지 않아 새는 것이 없다.
/// - 새 메일 알림 컨트롤러도 여기서 띄운다 — 앱을 띄우자마자 앞에서 받은 알림 · 누른 알림을 듣게.
void connectPushNavigation(
Ref ref,
GoRouter router, {
List<AppModule> modules = kAppModules,
}) {
void openPending() {
if (!ref.mounted) return;
final PushMessage? opened = ref
.read(pushEventsProvider.notifier)
.takeOpened();
if (opened == null) return;
router.go(locationForPush(opened.target, moduleRoutesOf, modules: modules));
}
ref
..listen<PushEvent?>(pushEventsProvider, (
PushEvent? previous,
PushEvent? next,
) {
if (next != null && next.opened) openPending();
})
..listen<PushState>(pushControllerProvider, (PushState? _, PushState _) {});
// 라우터보다 먼저 온 누른 알림 — 라우터를 만든 뒤에 옮긴다(만드는 중에는 옮기지 않는다)
scheduleMicrotask(openPending);
}
@@ -10,16 +10,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
///
/// | 세션 | 화면 |
/// | --- | --- |
/// | (세션과 상관없이) 앱을 업데이트해야 함([updateRequired]) | 업데이트 — 로그인 전 화면에서도(옛 앱이 로그인 API 를 잘못 부르지 않게) |
/// | 되살리는 중 · 되살리기 실패 | 시작 |
/// | 로그아웃 | 로그인 |
/// | 코드 단계 | 코드 |
/// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 |
/// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 |
///
/// 업데이트가 풀리면(서버가 최소 판을 내림) 업데이트 화면은 세션이 고른 화면으로 돌아간다(그 경로는 로그인 전 화면도 모듈도 아니다).
String? resolveSessionRedirect(
AsyncValue<SessionState> session,
String location, {
List<AppModule> modules = kAppModules,
bool updateRequired = false,
}) {
if (updateRequired) return _stayOr(location, AppRoutes.update);
final SessionState? state = session.value;
if (state == null) return _stayOr(location, AppRoutes.start);
+7
View File
@@ -132,4 +132,11 @@ abstract final class AppColors {
/// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막
static const Color backdrop = Color(0x520B3640);
// ── 앱 보드에서 정한 색 (`App1Shell` · `App1Push` — 웹 토큰에 없다) ──
/// 누를 수 없는 스위치의 손잡이
static const Color switchThumbDisabled = Color(0xFFC5CBD3);
/// 둥근 진행 표시(켜는 중 · 끄는 중)의 바탕 고리
static const Color progressRingTrack = Color(0xFFD5ECEE);
}
+20
View File
@@ -209,4 +209,24 @@ abstract final class AppSize {
/// 시트의 [찾기]처럼 채운 주 단추의 최소 폭
static const double primaryActionMinWidth = 120;
// ── 앱 보드에서 정한 크기 (`App1Shell` · `App1Push` — 2026-11-07 확정) ──
/// 설정 화면의 두 줄 칸(내 정보 · 새 메일 알림)의 최소 높이
static const double settingsRowMinHeight = 68;
/// 설정 화면의 한 줄 칸(앱 버전 · 오픈소스 라이선스)의 최소 높이
static const double settingsLineMinHeight = 52;
/// 설정 화면 내 정보의 동그란 사진
static const double settingsAvatar = 48;
/// 스위치의 누르는 곳 — 보이는 바는 52×32(Material 3)
static const double switchTouchWidth = 64;
/// 스위치 바의 테두리
static const double switchOutlineWidth = 2;
/// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께
static const double progressRing = 22;
static const double progressRingStroke = 2.6;
}
+32
View File
@@ -98,6 +98,7 @@ abstract final class AppTheme {
),
),
navigationBarTheme: _navigationBarTheme(scheme, textTheme),
switchTheme: _switchTheme(scheme),
// 아래에서 올라오는 시트(옮기기 · 조건으로 찾기)의 가림막 — 대화상자 가림막 토큰(`--color-backdrop`). Flutter 기본은 검정 54%
bottomSheetTheme: const BottomSheetThemeData(
modalBarrierColor: AppColors.backdrop,
@@ -147,6 +148,37 @@ abstract final class AppTheme {
);
}
/// 스위치 — 앱 보드 `App1Shell` · `App1Push`(52×32 바 · 테두리 2 · 손잡이 켜짐 24 · 꺼짐 16 은 Material 3 그대로).
/// 켜짐은 청록 바에 흰 손잡이, 꺼짐은 흰 바에 회색 테두리 · 손잡이, 누를 수 없으면 옅은 회색(흐리게 하지 않는다)
static SwitchThemeData _switchTheme(ColorScheme scheme) {
bool disabled(Set<WidgetState> states) =>
states.contains(WidgetState.disabled);
bool on(Set<WidgetState> states) => states.contains(WidgetState.selected);
return SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (disabled(states)) return AppColors.switchThumbDisabled;
return on(states) ? scheme.onPrimary : scheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (disabled(states)) return AppColors.surfaceMuted;
return on(states) ? scheme.primary : scheme.surface;
}),
trackOutlineColor: WidgetStateProperty.resolveWith<Color>((
Set<WidgetState> states,
) {
if (disabled(states)) return AppColors.borderStrong;
return on(states) ? scheme.primary : scheme.outline;
}),
trackOutlineWidth: const WidgetStatePropertyAll<double>(
AppSize.switchOutlineWidth,
),
);
}
static OutlineInputBorder _inputBorder(
Color color, {
double width = AppSize.borderWidth,
@@ -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();
+17 -2
View File
@@ -6,6 +6,8 @@ import 'package:arasystem_app/core/api/user_agent.dart';
import 'package:arasystem_app/core/constants/app_info_provider.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/core/files/picked_files.dart';
import 'package:arasystem_app/core/push/firebase_push_messaging.dart';
import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/storage/first_run_marker.dart';
import 'package:arasystem_app/core/storage/secure_store.dart';
import 'package:arasystem_app/core/storage/token_store.dart';
@@ -39,8 +41,19 @@ Future<void> main() async {
firstRun: PrefsFirstRunMarker(),
);
await tokenStore.prepare();
final ({String? appVersion, String userAgent}) device =
await loadAppDeviceInfo();
final ({
String? appVersion,
String? packageName,
String userAgent,
int? androidSdkInt,
})
device = await loadAppDeviceInfo();
// 새 메일 알림(FCM) — Firebase 를 띄우고 플러그인의 소식 줄(FID · 받은 알림 · 누른 알림)을 지금 듣는다.
// 띄우지 못하면(google-services.json 없이 빌드 · 5초 넘음) 알림 없이 돈다 — 설정 줄은 "이 휴대폰에서는 받을 수 없음"
final PushMessaging pushMessaging = await FirebasePushMessaging.start(
sdkInt: device.androidSdkInt,
);
// 지난 실행이 받아 둔 파일(메일 첨부)과 고른 파일의 복사본(쓰기 화면의 첨부)을 지운다 — 기다리지 않는다
// (저장소가 차례를 지켜 새로 받는 파일 · 새로 고른 파일은 지우지 않는다)
@@ -57,8 +70,10 @@ Future<void> main() async {
tokenStoreProvider.overrideWithValue(tokenStore),
userAgentProvider.overrideWithValue(device.userAgent),
appVersionProvider.overrideWithValue(device.appVersion),
appPackageNameProvider.overrideWithValue(device.packageName),
downloadedFileStoreProvider.overrideWithValue(downloadedFiles),
pickedFileStoreProvider.overrideWithValue(pickedFiles),
pushMessagingProvider.overrideWithValue(pushMessaging),
],
child: const App(),
),