generated from ttipo/project-base
feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭
앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다. - firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다. google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다). - 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다. - 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다 (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다). - 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다. - 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다). - 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다. - 시험 932 → 1,056. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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());
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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을 찾아 알림을 켜 주세요.';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user