generated from ttipo/project-base
feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21
@@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다).
|
||||
# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림")
|
||||
app/google-services.json
|
||||
|
||||
@@ -42,3 +42,22 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림").
|
||||
// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다.
|
||||
// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게.
|
||||
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
|
||||
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다.
|
||||
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다.
|
||||
val firebaseConfig = file("google-services.json")
|
||||
if (firebaseConfig.exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
} else {
|
||||
gradle.taskGraph.whenReady {
|
||||
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
|
||||
throw GradleException(
|
||||
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- API 서버에 닿으려면 release 판에도 있어야 한다 (debug · profile 매니페스트에만 있던 것을 옮겼다) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!--
|
||||
새 메일 알림 — 안드로이드 13 이상은 알림을 보이려면 이 권한을 직원이 허락해야 한다(firebase_messaging 매니페스트에도 있다 — 뜻을 보이려고 적는다).
|
||||
묻는 창은 [알림 켜기]를 누를 때만 띄운다(core/push/push_controller.dart)
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- 광고 ID 를 쓰지 않는다 — 라이브러리 매니페스트가 넣어도 뺀다(Play 의 광고 ID 선언을 "아니요"로) -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
|
||||
<!--
|
||||
백업 끔: 리프레시 토큰은 flutter_secure_storage 가 이 기기의 Keystore 키로 암호화해 둔다.
|
||||
Google 드라이브 백업 · 기기 옮기기로 다른 기기에 복원되면 키가 없어 읽지 못하고(플러그인 README),
|
||||
@@ -49,6 +57,24 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/downloaded_file_paths" />
|
||||
</provider>
|
||||
<!--
|
||||
새 메일 알림(FCM) — 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다. 서버 메시지에 없는 값을 여기서 채운다.
|
||||
채널: 짝 — NewMailNotifications.kt(채널을 만든다) · 서버 channel_id. 아이콘 · 색은 서버가 싣지 않아 늘 이 기본값이다
|
||||
(색: 짝 — Dart AppColors.brand #0E7C86. 작은 아이콘을 바꾸려면 drawable 의 ic_stat_ara 그림만 바꾼다 — flutter/README "앱 아이콘 · 알림 아이콘")
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/new_mail_channel_id" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_ara" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_accent" />
|
||||
<!-- 로그인 전에는 FCM 에 등록하지 않는다 — 앱이 register() 를 부를 때만(core/push/push_controller.dart) -->
|
||||
<meta-data
|
||||
android:name="firebase_messaging_auto_init_enabled"
|
||||
android:value="false" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -28,6 +28,8 @@ import java.util.Locale
|
||||
* 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다.
|
||||
* 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`.
|
||||
* 경로 · 주소 · 파일 이름은 로그에 남기지 않는다.
|
||||
*
|
||||
* 알림 채널(`kr.it.arasystem.app/notifications`)은 [NewMailNotifications] 가 맡는다 — 새 메일 알림 채널도 화면을 만들 때 그것이 만든다.
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
/** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */
|
||||
@@ -49,6 +51,11 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
// 새 메일 알림 — 채널은 첫 알림보다 먼저 있어야 한다(서버의 기기 줄은 앱을 연 뒤에야 생긴다)
|
||||
NewMailNotifications(this).apply {
|
||||
createChannel()
|
||||
register(flutterEngine.dartExecutor.binaryMessenger)
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package kr.it.arasystem.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* 새 메일 알림의 채널과 알림창 — 채널 `kr.it.arasystem.app/notifications`.
|
||||
*
|
||||
* **짝: Dart `core/push/notification_system.dart` 의 `PlatformNotificationSystem`** — 채널 이름 · 부르는 이름 · 답의 칸과 값(한쪽을 고치면 둘 다).
|
||||
*
|
||||
* - 알림 채널 `new_mail`(짝: 서버 `channel_id` · 매니페스트의 기본 채널 — 값은 `R.string.new_mail_channel_id`)을 앱을 열 때마다 만든다.
|
||||
* 중요도 HIGH(소리 · 머리 알림) — 한 번 만든 채널의 중요도는 앱이 바꾸지 못한다(직원이 바꾼다). 이름 · 설명은 다시 만들 때 바뀐다.
|
||||
* 알림은 앱이 뒤에 있거나 꺼져 있을 때 FCM 이 이 채널로 띄운다(앱 코드는 돌지 않는다).
|
||||
* - `status` → `{appEnabled, channelEnabled, sdkInt}` — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다.
|
||||
* - `openSettings` → 새 메일 채널만 꺼졌으면 그 채널 설정, 아니면 이 앱의 알림 설정(24 · 25 는 앱 정보). 답은 opened · failed.
|
||||
* - `clearNewMail` → 알림창의 "새 메일 N통"(tag `new-mail` — 짝: 서버 tag. FCM 은 tag 를 주면 id 0 으로 띄운다)만 지운다.
|
||||
*
|
||||
* 주소 · 토큰 · FID 는 다루지도 로그에 남기지도 않는다.
|
||||
*/
|
||||
class NewMailNotifications(private val activity: Activity) {
|
||||
private val manager = NotificationManagerCompat.from(activity)
|
||||
|
||||
private val channelId: String
|
||||
get() = activity.getString(R.string.new_mail_channel_id)
|
||||
|
||||
/** 채널을 만든다 — 이미 있으면 이름 · 설명만 바뀐다(채널이 없는 판은 아무것도 하지 않는다) */
|
||||
fun createChannel() {
|
||||
val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(activity.getString(R.string.new_mail_channel_name))
|
||||
.setDescription(activity.getString(R.string.new_mail_channel_description))
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun register(messenger: BinaryMessenger) {
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
STATUS_METHOD -> result.success(status())
|
||||
OPEN_SETTINGS_METHOD -> result.success(openSettings())
|
||||
CLEAR_NEW_MAIL_METHOD -> {
|
||||
clearNewMail()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(): Map<String, Any> = mapOf(
|
||||
KEY_APP_ENABLED to manager.areNotificationsEnabled(),
|
||||
KEY_CHANNEL_ENABLED to isChannelEnabled(),
|
||||
KEY_SDK_INT to Build.VERSION.SDK_INT,
|
||||
)
|
||||
|
||||
/** 새 메일 채널이 켜져 있다 — 채널이 없는 판(24 · 25)이거나 채널을 아직 만들지 않았으면 참 */
|
||||
private fun isChannelEnabled(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
|
||||
val channel = manager.getNotificationChannelCompat(channelId) ?: return true
|
||||
return channel.importance != NotificationManagerCompat.IMPORTANCE_NONE
|
||||
}
|
||||
|
||||
/** 휴대폰 설정을 연다 — 가장 알맞은 화면부터, 열리지 않으면 다음 화면 */
|
||||
private fun openSettings(): String {
|
||||
val packageName = activity.packageName
|
||||
val intents = buildList {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (manager.areNotificationsEnabled() && !isChannelEnabled()) {
|
||||
add(
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId),
|
||||
)
|
||||
}
|
||||
add(
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName),
|
||||
)
|
||||
}
|
||||
add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)))
|
||||
}
|
||||
for (intent in intents) {
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
return RESULT_OPENED
|
||||
} catch (error: ActivityNotFoundException) {
|
||||
// 이 화면이 없는 기기 — 다음 화면을 연다
|
||||
} catch (error: SecurityException) {
|
||||
// 열 수 없다 — 다음 화면을 연다
|
||||
}
|
||||
}
|
||||
return RESULT_FAILED
|
||||
}
|
||||
|
||||
private fun clearNewMail() {
|
||||
manager.cancel(NEW_MAIL_TAG, FCM_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 짝: Dart `PlatformNotificationSystem.channel` */
|
||||
const val CHANNEL = "kr.it.arasystem.app/notifications"
|
||||
const val STATUS_METHOD = "status"
|
||||
const val OPEN_SETTINGS_METHOD = "openSettings"
|
||||
const val CLEAR_NEW_MAIL_METHOD = "clearNewMail"
|
||||
|
||||
// 짝: Dart `PlatformNotificationSystem` 의 appEnabledKey · channelEnabledKey · sdkIntKey · openedResult
|
||||
const val KEY_APP_ENABLED = "appEnabled"
|
||||
const val KEY_CHANNEL_ENABLED = "channelEnabled"
|
||||
const val KEY_SDK_INT = "sdkInt"
|
||||
const val RESULT_OPENED = "opened"
|
||||
const val RESULT_FAILED = "failed"
|
||||
|
||||
/** 짝: 서버가 보내는 알림의 tag(`android.notification.tag`) — "새 메일 N통" · "알림이 멈췄습니다"가 함께 쓴다 */
|
||||
const val NEW_MAIL_TAG = "new-mail"
|
||||
|
||||
/** FCM 은 tag 를 준 알림을 id 0 으로 띄운다 */
|
||||
const val FCM_NOTIFICATION_ID = 0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림의 강조 색(알림창의 작은 아이콘 둘레 · 앱 이름) — 짝: Dart AppColors.brand(#0E7C86) -->
|
||||
<color name="notification_accent">#0E7C86</color>
|
||||
<!-- 앱 아이콘 바탕 — 남색(웹의 탭 · 홈 화면 아이콘과 같은 #0B3640 — 짝: Dart AppColors.brandSurface) -->
|
||||
<color name="ic_launcher_background">#0B3640</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림 채널 id — 짝: 서버 channel_id · 매니페스트의 기본 채널(default_notification_channel_id) · NewMailNotifications.kt -->
|
||||
<string name="new_mail_channel_id" translatable="false">new_mail</string>
|
||||
<!--
|
||||
휴대폰 설정의 알림 카테고리에 보이는 채널 이름 · 설명 — 앱 보드 App1Push ⑫ 의 글 그대로(화면 문구다 — 디자인 README 「화면 문구」).
|
||||
채널을 다시 만들 때 이름 · 설명은 바뀐다(중요도는 바뀌지 않는다)
|
||||
-->
|
||||
<string name="new_mail_channel_name">새 메일</string>
|
||||
<string name="new_mail_channel_description">새 메일이 오면 알려 드립니다.</string>
|
||||
</resources>
|
||||
@@ -21,6 +21,9 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
// 새 메일 알림(FCM) — google-services.json 을 안드로이드 리소스로 바꾼다. 4.4.4 는 firebase_core 5.1.0 이 시험한 판
|
||||
// (그 pubspec 의 google_services_gradle_plugin_version). 거는 것은 app/build.gradle.kts — 파일이 있을 때만
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,8 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_secure_storage_darwin
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -14,6 +16,8 @@ import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
@@ -9,6 +9,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: e6d8a6eb9da10e65906dda455eca96ee7a6303b835cad74d667b1ff3bedd5d18
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.79"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -185,6 +193,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "21551c1205441d9d155660ea0a409530b65a8d66ce88002bab3f38d94a8638fc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.1"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ec9832bfec1662629d6d09abd65021357634b2c10964970d3e96ab18c9458db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: e19c73b369ae83f4580a0676597f9ac3a0cd91e1b18be69ea16299c153dd9310
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.0.1"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "1627f161b85bf842cba5227b02aafaad4e4ff9236a0a8490e6ec72b6fde294a5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "593d73330500896d9dd81680ee6207c69ad80f258712beb6de2e32dc14a9cb26"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
||||
@@ -24,6 +24,12 @@ dependencies:
|
||||
# 기기 정보(Android 판 · 기종) — User-Agent 만들기
|
||||
device_info_plus: ^13.3.0
|
||||
dio: ^5.9.2
|
||||
# 새 메일 알림(FCM) — Firebase 를 띄운다(core/push/firebase_push_messaging.dart). 5.1.0 은 2026-10-23 배포(FlutterFire).
|
||||
# 안드로이드 설정은 android/app/google-services.json(저장소에 넣지 않는다 — flutter/README "새 메일 알림")
|
||||
firebase_core: ^5.1.0
|
||||
# 새 메일 알림 — FID 등록(register · onRegistered) · 알림 권한 · 누른 알림. 17.0.1 은 2026-10-23 배포(FlutterFire).
|
||||
# 토큰 API(getToken · onTokenRefresh)는 17.0 부터 deprecated 라 쓰지 않는다. 이 파일 밖에서는 import 하지 않는다
|
||||
firebase_messaging: ^17.0.1
|
||||
flutter_dotenv: ^5.2.1
|
||||
flutter_riverpod: ^3.3.1
|
||||
flutter_secure_storage: ^10.0.0
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -159,7 +160,7 @@ void main() {
|
||||
|
||||
await tester.tap(tab('설정'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(SettingsTexts.home), findsOneWidget);
|
||||
expect(find.byType(SettingsPage), findsOneWidget);
|
||||
|
||||
await tester.tap(tab('메일'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -216,7 +217,7 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('설정 첫 화면의 절과 [로그아웃] — 누르면 로그인 화면으로', (
|
||||
testWidgets('설정은 한 화면 — 절과 [로그아웃], 확인 창에서 [로그아웃]을 고르면 로그인 화면으로', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final GoRouter router = await pumpSignedIn(tester);
|
||||
@@ -224,22 +225,47 @@ void main() {
|
||||
await tester.tap(tab('설정'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget);
|
||||
for (final String item in <String>[
|
||||
SettingsTexts.account,
|
||||
SettingsTexts.notifications,
|
||||
SettingsTexts.about,
|
||||
SettingsTexts.support,
|
||||
SettingsTexts.signOut,
|
||||
]) {
|
||||
expect(page(item), findsOneWidget, reason: item);
|
||||
}
|
||||
|
||||
await tester.tap(page(SettingsTexts.signOut));
|
||||
// [로그아웃]은 목록 끝이다 — 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다
|
||||
final Finder signOut = find.widgetWithText(
|
||||
OutlinedButton,
|
||||
SettingsTexts.signOut,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
signOut,
|
||||
200,
|
||||
scrollable: find.descendant(
|
||||
of: find.byType(SettingsPage),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(signOut);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(SettingsTexts.signOutTitle), findsOneWidget);
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), isEmpty);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text(SettingsTexts.signOut),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(router.routerDelegate.currentConfiguration.uri.path, '/login');
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
|
||||
expect(h.push.unregistered, 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/app_config/app_config.dart';
|
||||
import 'package:arasystem_app/core/app_config/app_config_controller.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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/provider_harness.dart';
|
||||
import '../../support/push_fakes.dart';
|
||||
|
||||
/// 앱 설정 · 최소 판 확인 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정")
|
||||
void main() {
|
||||
group('판 읽기 · 견주기', () {
|
||||
test('숫자 셋 — 모자란 칸은 0, `-` · `+` 뒤는 버린다', () {
|
||||
expect(AppVersion.tryParse('1.2.3'), const AppVersion(1, 2, 3));
|
||||
expect(AppVersion.tryParse('1.2'), const AppVersion(1, 2, 0));
|
||||
expect(AppVersion.tryParse('3'), const AppVersion(3, 0, 0));
|
||||
expect(AppVersion.tryParse(' 1.2.3 '), const AppVersion(1, 2, 3));
|
||||
expect(AppVersion.tryParse('1.2.3-beta.1'), const AppVersion(1, 2, 3));
|
||||
expect(AppVersion.tryParse('1.2.3+7'), const AppVersion(1, 2, 3));
|
||||
expect(AppVersion.tryParse('1.0.0-rc+4'), const AppVersion(1, 0, 0));
|
||||
expect(AppVersion.tryParse('1.2.3')?.toString(), '1.2.3');
|
||||
expect(
|
||||
AppVersion.tryParse('9999.0.9999'),
|
||||
const AppVersion(9999, 0, 9999),
|
||||
reason: '칸마다 4자리까지 — 서버의 판 칸과 같다',
|
||||
);
|
||||
});
|
||||
|
||||
test('읽지 못하는 판은 null — 칸 넷 · 숫자 아님 · 빈 칸 · 다섯 자리 칸', () {
|
||||
for (final String? text in <String?>[
|
||||
null,
|
||||
'',
|
||||
'a.b.c',
|
||||
'1.2.3.4',
|
||||
'1..2',
|
||||
'1.-2.0',
|
||||
'.1.2',
|
||||
'1.2.',
|
||||
'12345.0.0',
|
||||
'1.2.10000',
|
||||
'v1.2.3',
|
||||
'-1.0.0',
|
||||
]) {
|
||||
expect(AppVersion.tryParse(text), isNull, reason: '$text');
|
||||
}
|
||||
});
|
||||
|
||||
test('칸마다 숫자로 견준다 — 1.10.0 은 1.9.9 보다 높다', () {
|
||||
expect(const AppVersion(1, 9, 9) < const AppVersion(1, 10, 0), isTrue);
|
||||
expect(const AppVersion(1, 99, 99) < const AppVersion(2, 0, 0), isTrue);
|
||||
expect(const AppVersion(1, 2, 3) < const AppVersion(1, 2, 3), isFalse);
|
||||
expect(
|
||||
const AppVersion(1, 2, 4).compareTo(const AppVersion(1, 2, 3)),
|
||||
greaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
test('업데이트해야 하는 때는 이 앱이 최소 판보다 낮을 때뿐 — 어느 쪽이든 읽지 못하면 막지 않는다', () {
|
||||
const AppVersion minimum = AppVersion(1, 0, 1);
|
||||
expect(
|
||||
isAppUpdateRequired(appVersion: '1.0.0', minimum: minimum),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isAppUpdateRequired(appVersion: '1.0.1', minimum: minimum),
|
||||
isFalse,
|
||||
);
|
||||
expect(isAppUpdateRequired(appVersion: '1.1', minimum: minimum), isFalse);
|
||||
expect(isAppUpdateRequired(appVersion: null, minimum: minimum), isFalse);
|
||||
expect(isAppUpdateRequired(appVersion: 'dev', minimum: minimum), isFalse);
|
||||
expect(isAppUpdateRequired(appVersion: '0.9.0', minimum: null), isFalse);
|
||||
});
|
||||
|
||||
test('서버에 알리는 판은 숫자 셋 꼴로 맞춘다', () {
|
||||
expect(normalizedAppVersion('1.2'), '1.2.0');
|
||||
expect(normalizedAppVersion('1.0.0-beta'), '1.0.0');
|
||||
expect(normalizedAppVersion('dev'), isNull);
|
||||
expect(normalizedAppVersion(null), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('응답 읽기 (AppConfig.fromJson)', () {
|
||||
test('안드로이드의 최소 판 · 알림을 쓸 수 있는지', () {
|
||||
expect(
|
||||
AppConfig.fromJson(const <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{
|
||||
'android': '1.2.0',
|
||||
'ios': null,
|
||||
},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
const AppConfig(
|
||||
minSupportedVersion: AppVersion(1, 2, 0),
|
||||
pushAvailable: true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('모양이 틀린 칸은 없는 것으로 본다(막지 않는다 · 알림은 모른다)', () {
|
||||
expect(AppConfig.fromJson(null), const AppConfig());
|
||||
expect(AppConfig.fromJson('1.0.0'), const AppConfig());
|
||||
expect(
|
||||
AppConfig.fromJson(const <String, Object?>{
|
||||
'minSupportedVersion': '1.0.0',
|
||||
'pushAvailable': 'yes',
|
||||
}),
|
||||
const AppConfig(),
|
||||
);
|
||||
expect(
|
||||
AppConfig.fromJson(const <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': 'latest'},
|
||||
'pushAvailable': false,
|
||||
}),
|
||||
const AppConfig(pushAvailable: false),
|
||||
);
|
||||
expect(
|
||||
AppConfig.fromJson(const <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'ios': '2.0.0'},
|
||||
}),
|
||||
const AppConfig(),
|
||||
reason: '다른 플랫폼의 판은 보지 않는다',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('저장소 — 로그인 없음 · 알림 끔', () {
|
||||
late ApiHarness api;
|
||||
|
||||
setUp(() => api = ApiHarness());
|
||||
|
||||
test('받은 값을 읽는다 — Bearer 를 붙이지 않고 알리지 않는다', () async {
|
||||
await api.prepare();
|
||||
await api.signIn();
|
||||
api.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
);
|
||||
|
||||
final AppConfig config = await AppConfigRepository(api.dio).fetch();
|
||||
|
||||
expect(config.pushAvailable, isTrue);
|
||||
final RequestOptions sent = api.server.requests.single;
|
||||
expect(bearerTokenOf(sent), isNull);
|
||||
expect(sent.extra[kSilentExtraKey], isTrue);
|
||||
});
|
||||
|
||||
test('앱의 저장소는 인증 줄 밖으로 보낸다 — 앞 요청의 미리 갱신을 기다리지 않는다', () async {
|
||||
// 앱을 막 띄웠다 — 보호 요청(me)이 보내기 전에 갱신하는데, 갱신이 아직 답하지 않는다
|
||||
final ProviderHarness h = ProviderHarness();
|
||||
await h.prepare(refreshToken: 'refresh-1');
|
||||
final Completer<void> refreshGate = Completer<void>();
|
||||
h.server
|
||||
..on('POST', ApiPaths.appRefresh, (_) async {
|
||||
await refreshGate.future;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: h.clock.now),
|
||||
});
|
||||
})
|
||||
..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()))
|
||||
..on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'pushAvailable': true}),
|
||||
);
|
||||
final ProviderContainer container = h.container();
|
||||
addTearDown(container.dispose);
|
||||
final Future<Response<Object?>> me = container
|
||||
.read(dioProvider)
|
||||
.get<Object?>(ApiPaths.me);
|
||||
await pumpEventQueue();
|
||||
expect(h.server.received('POST', ApiPaths.appRefresh), hasLength(1));
|
||||
|
||||
final AppConfig config = await container
|
||||
.read(appConfigRepositoryProvider)
|
||||
.fetch();
|
||||
|
||||
expect(config.pushAvailable, isTrue);
|
||||
expect(h.server.received('GET', ApiPaths.me), isEmpty, reason: '갱신 전');
|
||||
refreshGate.complete();
|
||||
await me;
|
||||
});
|
||||
|
||||
test('앱을 막 띄웠을 때(리프레시 토큰만) 미리 갱신하지 않는다', () async {
|
||||
await api.prepare();
|
||||
await api.coldStartWithRefreshToken('refresh-1');
|
||||
api.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'pushAvailable': true}),
|
||||
);
|
||||
|
||||
await AppConfigRepository(api.dio).fetch();
|
||||
|
||||
expect(api.server.received('POST', ApiPaths.appRefresh), isEmpty);
|
||||
expect(api.server.requests, hasLength(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'그 경로가 없는 옛 서버(404 RES_001)는 받은 것으로 본다 — 최소 판 없음 · 알림 쓸 수 없음',
|
||||
() async {
|
||||
await api.prepare();
|
||||
api.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.fail(404, 'RES_001'),
|
||||
);
|
||||
|
||||
expect(
|
||||
await AppConfigRepository(api.dio).fetch(),
|
||||
AppConfig.notProvided,
|
||||
);
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('그 밖의 실패는 DioException 으로 던지고 알리지 않는다', () async {
|
||||
await api.prepare();
|
||||
api.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.fail(503, 'SYS_001'),
|
||||
);
|
||||
|
||||
expect(
|
||||
await errorOf(AppConfigRepository(api.dio).fetch()),
|
||||
isA<DioException>(),
|
||||
);
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('컨트롤러 — 띄울 때 한 번 · 앞으로 올 때 1시간 · 실패는 1분', () {
|
||||
late ProviderHarness h;
|
||||
late ProviderContainer container;
|
||||
late int requests;
|
||||
late FakeResponse Function() respond;
|
||||
|
||||
setUp(() async {
|
||||
h = ProviderHarness();
|
||||
requests = 0;
|
||||
respond = () => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': true,
|
||||
});
|
||||
h.server.on('GET', ApiPaths.appConfig, (_) {
|
||||
requests++;
|
||||
return respond();
|
||||
});
|
||||
await h.prepare();
|
||||
container = ProviderContainer(
|
||||
overrides: <Override>[
|
||||
...h.overrides,
|
||||
appForegroundProvider.overrideWith(FakeForeground.new),
|
||||
appVersionProvider.overrideWithValue('1.0.0'),
|
||||
],
|
||||
retry: noAutomaticRetry,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container
|
||||
..listen(appConfigProvider, (_, _) {})
|
||||
..listen(appUpdateRequiredProvider, (_, _) {})
|
||||
..listen(pushAvailableProvider, (_, _) {});
|
||||
});
|
||||
|
||||
void comeBack() {
|
||||
final FakeForeground foreground =
|
||||
container.read(appForegroundProvider.notifier) as FakeForeground;
|
||||
foreground
|
||||
..set(false)
|
||||
..set(true);
|
||||
}
|
||||
|
||||
Future<void> idle() async {
|
||||
for (int turn = 0; turn < 40; turn++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
test('만들자마자 한 번 묻고, 앞으로 와도 1시간 안이면 다시 묻지 않는다', () async {
|
||||
await pumpEventsUntil(
|
||||
() => container.read(appConfigProvider).fetchedAt != null,
|
||||
);
|
||||
expect(requests, 1);
|
||||
expect(container.read(pushAvailableProvider), isTrue);
|
||||
expect(container.read(appUpdateRequiredProvider), isFalse);
|
||||
|
||||
h.clock.advance(const Duration(minutes: 59));
|
||||
comeBack();
|
||||
await idle();
|
||||
expect(requests, 1);
|
||||
|
||||
h.clock.advance(const Duration(minutes: 1));
|
||||
comeBack();
|
||||
await pumpEventsUntil(() => requests == 2);
|
||||
});
|
||||
|
||||
test('최소 판이 이 앱보다 높으면 업데이트해야 하고, 내리면 다음 확인에서 풀린다', () async {
|
||||
respond = () => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.1'},
|
||||
'pushAvailable': true,
|
||||
});
|
||||
await pumpEventsUntil(() => container.read(appUpdateRequiredProvider));
|
||||
|
||||
respond = () => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': true,
|
||||
});
|
||||
h.clock.advance(const Duration(hours: 1));
|
||||
comeBack();
|
||||
await pumpEventsUntil(() => !container.read(appUpdateRequiredProvider));
|
||||
});
|
||||
|
||||
test('받지 못하면 막지 않고 알리지 않는다 — 앞으로 올 때 1분 간격으로 다시 묻는다', () async {
|
||||
respond = () => FakeResponse.offline;
|
||||
await pumpEventsUntil(
|
||||
() => container.read(appConfigProvider).failedAt != null,
|
||||
);
|
||||
expect(container.read(appUpdateRequiredProvider), isFalse);
|
||||
expect(container.read(pushAvailableProvider), isNull);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
h.clock.advance(const Duration(seconds: 59));
|
||||
comeBack();
|
||||
await idle();
|
||||
expect(requests, 1);
|
||||
|
||||
respond = () =>
|
||||
FakeResponse.ok(<String, Object?>{'pushAvailable': false});
|
||||
h.clock.advance(const Duration(seconds: 1));
|
||||
comeBack();
|
||||
await pumpEventsUntil(
|
||||
() => container.read(pushAvailableProvider) == false,
|
||||
);
|
||||
expect(requests, 2);
|
||||
});
|
||||
|
||||
test('받은 뒤의 실패는 받은 값을 그대로 둔다', () async {
|
||||
await pumpEventsUntil(
|
||||
() => container.read(appConfigProvider).fetchedAt != null,
|
||||
);
|
||||
respond = () => FakeResponse.fail(503, 'SYS_001');
|
||||
await container.read(appConfigProvider.notifier).refresh();
|
||||
|
||||
expect(container.read(pushAvailableProvider), isTrue);
|
||||
expect(container.read(appConfigProvider).failedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('기기 시계를 뒤로 돌렸으면 지난 시간을 모르니 묻는다', () async {
|
||||
await pumpEventsUntil(() => requests == 1);
|
||||
await idle();
|
||||
h.clock.advance(const Duration(minutes: -10));
|
||||
comeBack();
|
||||
await pumpEventsUntil(() => requests == 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/core/api/session_events.dart';
|
||||
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/constants/screen_titles.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/router/app_routes.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/compose_fakes.dart';
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/mail_harness.dart';
|
||||
import '../../support/push_fakes.dart';
|
||||
|
||||
/// 앱 전체에서 본 새 메일 알림 — 누른 알림의 이동 · 쓰기 화면이 내려갈 때 마저 저장 · 앞에서 받은 알림의 새 메일 확인
|
||||
void main() {
|
||||
late MailAppHarness h;
|
||||
late FakeComposeBackend c;
|
||||
late FakePushMessaging messaging;
|
||||
|
||||
Future<ComposeQuote> inlineQuote(ComposeQuoteRequest request) async =>
|
||||
initialQuoteOf(request);
|
||||
|
||||
List<Override> pushOverrides(FakePushMessaging fake) => <Override>[
|
||||
pushMessagingProvider.overrideWithValue(fake),
|
||||
notificationSystemProvider.overrideWithValue(FakeNotificationSystem()),
|
||||
pushPreferencesProvider.overrideWithValue(MemoryPushPreferences()),
|
||||
];
|
||||
|
||||
setUp(() {
|
||||
h = MailAppHarness();
|
||||
c = FakeComposeBackend(h.server, h.mail);
|
||||
messaging = FakePushMessaging()..current = PushPermission.granted;
|
||||
h.overrides.addAll(<Override>[
|
||||
composeQuoteBuilderProvider.overrideWithValue(inlineQuote),
|
||||
...pushOverrides(messaging),
|
||||
]);
|
||||
h.server.serverTime = kTestNow;
|
||||
});
|
||||
|
||||
Future<void> start(WidgetTester tester, {String? location}) async {
|
||||
c.install();
|
||||
h.mail.seed(kInboxId, 2);
|
||||
await h.pump(tester, location: location);
|
||||
}
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
|
||||
|
||||
group('누른 알림 — 늘 메일 탭 받은편지함', () {
|
||||
testWidgets('다른 탭(설정)에 있다가 누르면 받은편지함으로 간다', (WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await tester.tap(find.text(SettingsTexts.home).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.location(), '/settings');
|
||||
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(h.location(), MailRoutes.home);
|
||||
expect(find.byType(MailListPage), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('읽기 화면에 있다가 누르면 읽기 화면을 닫고 받은편지함으로 간다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.message('m1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MessageReaderPage), findsOneWidget);
|
||||
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MessageReaderPage), findsNothing);
|
||||
expect(h.location(), MailRoutes.home);
|
||||
});
|
||||
|
||||
testWidgets('꺼져 있다가 뜬 알림은 세션을 되살린 뒤 받은편지함 — 한 번만 가져간다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
messaging.initial = kNewMailPush;
|
||||
await start(tester);
|
||||
|
||||
expect(h.location(), MailRoutes.home);
|
||||
expect(
|
||||
h.container.read(pushEventsProvider.notifier).takeOpened(),
|
||||
isNull,
|
||||
reason: '라우터가 가져갔다',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('로그아웃 상태면 누른 알림은 로그인 화면에 머문다', (WidgetTester tester) async {
|
||||
final LoginHarness login = LoginHarness();
|
||||
final FakePushMessaging fake = FakePushMessaging();
|
||||
await login.pump(tester, overrides: pushOverrides(fake));
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
|
||||
fake.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('쓰기 화면이 알림으로 내려가면 임시보관함에 마저 저장한다', () {
|
||||
testWidgets('바꾼 것이 있으면 쓰다 만 주소까지 저장하고 "임시보관함에 저장했습니다."', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.compose));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey<String>('compose-body')),
|
||||
'알림을 누르기 전에 쓰던 글',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
// 저장은 화면이 내려간 뒤 뒤에서 이어진다
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(h.location(), MailRoutes.home);
|
||||
expect(c.saves.single.call, 'POST');
|
||||
expect(c.saves.single.body['body'], contains('알림을 누르기 전에 쓰던 글'));
|
||||
expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('저장하지 못하면 그 코드의 사전 문구로 알린다', (WidgetTester tester) async {
|
||||
await start(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.compose));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey<String>('compose-body')),
|
||||
'저장이 실패할 글',
|
||||
);
|
||||
await tester.pump();
|
||||
c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001'));
|
||||
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(c.saves, hasLength(1));
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
expect(snack(MailComposeTexts.savedOnLeave), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('세션에 제한이 생겨(갱신 응답) 제한 단계로 옮겨 내려가면 알리지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.compose));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey<String>('compose-body')),
|
||||
'제한 전에 쓰던 글',
|
||||
);
|
||||
await tester.pump();
|
||||
// 서버는 제한된 세션의 임시 저장을 막는다(403 AUTH_010) — me 도 제한을 말한다
|
||||
c.draftFailures
|
||||
..add(FakeResponse.fail(403, 'AUTH_010'))
|
||||
..add(FakeResponse.fail(403, 'AUTH_010'));
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.me,
|
||||
(_) => FakeResponse.ok(sessionJson(restriction: 'passwordChange')),
|
||||
);
|
||||
|
||||
h.container
|
||||
.read(sessionEventsProvider)
|
||||
.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(h.location(), AppRoutes.passwordChange);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(h.notifier.shownCodes, isNot(contains('AUTH_010')));
|
||||
expect(snack(MailComposeTexts.savedOnLeave), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('바꾼 것이 없고 저장한 적도 없으면 조용히 내려간다', (WidgetTester tester) async {
|
||||
await start(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.compose));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(c.saves, isEmpty);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('업데이트 화면 (최소 판)', () {
|
||||
testWidgets('이 앱이 최소 판보다 낮아지면 어느 화면에서든 업데이트 화면 — 쓰던 메일은 저장하고, 풀리면 돌아온다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
String minimum = '1.0.0';
|
||||
h.overrides.add(appVersionProvider.overrideWithValue('1.0.0'));
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': minimum},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
);
|
||||
await start(tester);
|
||||
expect(h.location(), MailRoutes.home);
|
||||
unawaited(h.router.push<void>(MailRoutes.compose));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey<String>('compose-body')),
|
||||
'업데이트 전에 쓰던 글',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
minimum = '1.1.0';
|
||||
unawaited(h.container.read(appConfigProvider.notifier).refresh());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(h.location(), '/update');
|
||||
expect(find.text(ScreenTitles.update), findsOneWidget);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(c.saves.single.call, 'POST');
|
||||
expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget);
|
||||
|
||||
// 업데이트 화면에서는 누른 알림도 업데이트 화면으로 돌아온다
|
||||
messaging.tap(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.location(), '/update');
|
||||
|
||||
minimum = '1.0.0';
|
||||
unawaited(h.container.read(appConfigProvider.notifier).refresh());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.location(), MailRoutes.home);
|
||||
});
|
||||
|
||||
testWidgets('로그인 전 화면에서도 업데이트 화면이다', (WidgetTester tester) async {
|
||||
final LoginHarness login = LoginHarness();
|
||||
login.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '2.0.0'},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
);
|
||||
await login.pump(
|
||||
tester,
|
||||
overrides: <Override>[appVersionProvider.overrideWithValue('1.0.0')],
|
||||
);
|
||||
|
||||
expect(find.text(ScreenTitles.update), findsOneWidget);
|
||||
expect(find.byType(LoginPage), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('앞에 있을 때 온 새 메일 알림', () {
|
||||
testWidgets('목록이 보이면 10초 간격을 건너뛰고 바로 새 메일을 확인한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
final int asked = h.mail.stateRequests;
|
||||
|
||||
messaging.receive(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.mail.stateRequests, asked + 1);
|
||||
|
||||
// 세션 끝 알림은 새 메일이 아니다
|
||||
messaging.receive(kSessionEndedPush);
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.mail.stateRequests, asked + 1);
|
||||
// 1분 차례는 그대로 이어 간다
|
||||
h.clock.advance(kMailStatePollInterval);
|
||||
await tester.pump(kMailStatePollInterval);
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.mail.stateRequests, asked + 2);
|
||||
});
|
||||
|
||||
testWidgets('묻는 중에 오면 그 물음이 끝난 뒤 한 번 더 묻는다', (WidgetTester tester) async {
|
||||
await start(tester);
|
||||
final int asked = h.mail.stateRequests;
|
||||
final Completer<void> gate = Completer<void>();
|
||||
h.mail.stateGate = gate;
|
||||
h.clock.advance(kMailStatePollInterval);
|
||||
await tester.pump(kMailStatePollInterval);
|
||||
expect(h.mail.stateRequests, asked + 1, reason: '1분 차례의 물음이 서버에 닿았다');
|
||||
|
||||
messaging.receive(kNewMailPush);
|
||||
await tester.pump();
|
||||
expect(h.mail.stateRequests, asked + 1, reason: '묻는 중에는 겹쳐 묻지 않는다');
|
||||
|
||||
gate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(h.mail.stateRequests, asked + 2);
|
||||
});
|
||||
|
||||
testWidgets('목록이 안 보이면(설정 탭) 묻지 않고, 돌아오면 지금 규칙대로', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start(tester);
|
||||
await tester.tap(find.text(SettingsTexts.home).last);
|
||||
await tester.pumpAndSettle();
|
||||
final int asked = h.mail.stateRequests;
|
||||
|
||||
messaging.receive(kNewMailPush);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(h.mail.stateRequests, asked);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/api/session_events.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.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_preferences.dart';
|
||||
import 'package:arasystem_app/core/push/push_state.dart';
|
||||
import 'package:arasystem_app/core/utils/app_foreground.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:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/push_fakes.dart';
|
||||
|
||||
/// 새 메일 알림 컨트롤러 — 진짜 세션 노티파이어와 가짜 FCM · 알림창 · 서버로 흐름을 본다(계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘")
|
||||
void main() {
|
||||
late PushHarness h;
|
||||
late ProviderContainer container;
|
||||
|
||||
/// 앱을 띄운다 — [refreshToken] 이 있으면 세션을 되살린다(갱신 → me → 등록)
|
||||
Future<void> start({
|
||||
String? refreshToken = 'refresh-1',
|
||||
PushPermission permission = PushPermission.granted,
|
||||
}) async {
|
||||
h.messaging.current = permission;
|
||||
await h.prepare(refreshToken: refreshToken);
|
||||
container = h.container();
|
||||
addTearDown(container.dispose);
|
||||
container
|
||||
..listen(sessionProvider, (_, _) {})
|
||||
..listen(pushControllerProvider, (_, _) {})
|
||||
..listen(pushEventsProvider, (_, _) {});
|
||||
}
|
||||
|
||||
PushState push() => container.read(pushControllerProvider);
|
||||
PushController controller() =>
|
||||
container.read(pushControllerProvider.notifier);
|
||||
|
||||
/// 이벤트 차례를 넉넉히 넘긴다 — "보내지 않았다"를 볼 때
|
||||
Future<void> idle() async {
|
||||
for (int turn = 0; turn < 60; turn++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> until(bool Function() condition, String reason) =>
|
||||
pumpEventsUntil(condition, reason: reason);
|
||||
|
||||
Future<void> signedIn() async {
|
||||
await until(
|
||||
() => container.read(sessionProvider).value is SignedIn,
|
||||
'세션 되살리기',
|
||||
);
|
||||
}
|
||||
|
||||
/// 앱이 뒤로 갔다가 앞으로 온다
|
||||
void comeBack() {
|
||||
final FakeForeground foreground =
|
||||
container.read(appForegroundProvider.notifier) as FakeForeground;
|
||||
foreground
|
||||
..set(false)
|
||||
..set(true);
|
||||
}
|
||||
|
||||
setUp(() => h = PushHarness());
|
||||
|
||||
group('등록 (PUT — FID 를 받았을 때 · 바뀌었을 때 · 세션이 열릴 때 한 번)', () {
|
||||
test('세션을 되살리고 켬 · 허락이면 FCM 에 등록하고 받은 FID 로 PUT 한다', () async {
|
||||
await start();
|
||||
await signedIn();
|
||||
await until(() => h.push.puts.isNotEmpty, '등록');
|
||||
|
||||
expect(h.push.puts.single.body, <String, Object?>{
|
||||
'fid': 'fid-1',
|
||||
'platform': 'android',
|
||||
'appVersion': '1.0.0',
|
||||
});
|
||||
expect(h.push.puts.single.bearer, 'access-2');
|
||||
expect(h.messaging.registerCalls, 1);
|
||||
await until(() => push().registered, '등록 상태');
|
||||
expect(h.prefs.values.lastFid, 'fid-1');
|
||||
expect(h.prefs.values.serverRegistered, isTrue);
|
||||
// 저절로 하는 등록은 알리지 않는다
|
||||
final RequestOptions sent = h.server
|
||||
.received('PUT', ApiPaths.pushRegistration)
|
||||
.single;
|
||||
expect(sent.extra[kSilentExtraKey], isTrue);
|
||||
});
|
||||
|
||||
test('FID 가 바뀌면 다시 PUT 하고, 같은 FID 가 다시 오면 보내지 않는다', () async {
|
||||
await start();
|
||||
await until(() => h.push.puts.isNotEmpty, '첫 등록');
|
||||
|
||||
h.messaging.emitFid('fid-1');
|
||||
await idle();
|
||||
expect(h.push.puts, hasLength(1));
|
||||
|
||||
h.messaging.emitFid('fid-2');
|
||||
await until(() => h.push.puts.length == 2, 'FID 바뀜');
|
||||
expect(h.push.puts.last.body['fid'], 'fid-2');
|
||||
await until(() => h.prefs.values.lastFid == 'fid-2', '마지막 FID');
|
||||
});
|
||||
|
||||
test('지난 실행에 올린 FID 가 있으면 등록 소식을 기다리지 않고 바로 PUT 한다(앱을 띄울 때 한 번)', () async {
|
||||
h.prefs.values = const PushPreferenceValues(
|
||||
lastFid: 'fid-old',
|
||||
serverRegistered: true,
|
||||
);
|
||||
h.messaging.fidOnRegister = null;
|
||||
|
||||
await start();
|
||||
await until(() => h.push.puts.isNotEmpty, '등록');
|
||||
|
||||
expect(h.push.puts.single.body['fid'], 'fid-old');
|
||||
// FCM 등록은 그대로 한 번 한다(FID 가 바뀌었으면 그 소식이 다시 PUT 한다)
|
||||
expect(h.messaging.registerCalls, 1);
|
||||
});
|
||||
|
||||
test('앞으로 올 때마다 PUT 하지 않는다', () async {
|
||||
await start();
|
||||
await until(() => h.push.puts.isNotEmpty, '등록');
|
||||
|
||||
comeBack();
|
||||
await idle();
|
||||
comeBack();
|
||||
await idle();
|
||||
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
test('끈 휴대폰 · 허락 전 · 채널 꺼짐이면 FCM 에도 서버에도 등록하지 않는다', () async {
|
||||
Future<void> expectNoRegistration(
|
||||
PushPermission permission,
|
||||
void Function() arrange,
|
||||
) async {
|
||||
h = PushHarness();
|
||||
arrange();
|
||||
await start(permission: permission);
|
||||
await signedIn();
|
||||
await idle();
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.push.deletes, isEmpty, reason: '서버 줄이 없다고 기억한다');
|
||||
expect(h.messaging.registerCalls, 0);
|
||||
}
|
||||
|
||||
await expectNoRegistration(
|
||||
PushPermission.granted,
|
||||
() => h.prefs.values = const PushPreferenceValues(enabled: false),
|
||||
);
|
||||
await expectNoRegistration(PushPermission.notAsked, () {});
|
||||
await expectNoRegistration(PushPermission.denied, () {});
|
||||
await expectNoRegistration(PushPermission.granted, () {
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'서버가 보낼 수 없다면(pushAvailable false · 그 경로가 없는 옛 서버) 로그인해도 등록하지 않는다',
|
||||
() async {
|
||||
Future<void> expectUnavailable(void Function() arrange) async {
|
||||
h = PushHarness();
|
||||
arrange();
|
||||
await start(refreshToken: null);
|
||||
await until(() => push().server == PushServer.unavailable, '앱 설정 받기');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appLogin,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'status': 'signedIn',
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(1, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
await container
|
||||
.read(sessionProvider.notifier)
|
||||
.signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
|
||||
await idle();
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.messaging.registerCalls, 0);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.unavailable);
|
||||
}
|
||||
|
||||
await expectUnavailable(
|
||||
() => h.push.config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': null},
|
||||
'pushAvailable': false,
|
||||
},
|
||||
);
|
||||
await expectUnavailable(
|
||||
() => h.push.configResponse = FakeResponse.fail(404, 'RES_001'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('서버 줄이 있을 수 있는 휴대폰을 끈 채 띄우면 지운다(DELETE)', () async {
|
||||
h.prefs.values = const PushPreferenceValues(
|
||||
enabled: false,
|
||||
serverRegistered: true,
|
||||
);
|
||||
|
||||
await start();
|
||||
await until(() => h.push.deletes.isNotEmpty, '지우기');
|
||||
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.push.deletes.single.extra[kSilentExtraKey], isTrue);
|
||||
await until(() => !h.prefs.values.serverRegistered, '서버 줄 없음');
|
||||
});
|
||||
|
||||
test('앞으로 왔을 때 권한이 꺼졌으면 DELETE, 다시 켜졌으면 PUT', () async {
|
||||
await start();
|
||||
await until(() => h.push.puts.isNotEmpty, '등록');
|
||||
await until(() => push().registered, '등록 상태');
|
||||
|
||||
h.messaging.current = PushPermission.blocked;
|
||||
comeBack();
|
||||
await until(() => h.push.deletes.isNotEmpty, '권한이 꺼진 것을 알았다');
|
||||
await until(() => !push().registered, '등록 없음');
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.blocked);
|
||||
|
||||
h.messaging.current = PushPermission.granted;
|
||||
comeBack();
|
||||
await until(() => h.push.puts.length == 2, '다시 켜짐');
|
||||
});
|
||||
|
||||
test('앞으로 왔을 때 새 메일 채널만 꺼졌으면 지운다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
);
|
||||
comeBack();
|
||||
await until(() => h.push.deletes.isNotEmpty, '채널이 꺼진 것을 알았다');
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.channelOff);
|
||||
});
|
||||
|
||||
test('PUT 이 실패해도 알리지 않고 다음에 앞으로 올 때 다시 한다', () async {
|
||||
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
|
||||
await start();
|
||||
await until(() => h.push.puts.isNotEmpty, '첫 등록');
|
||||
await idle();
|
||||
expect(push().registered, isFalse);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
h.push.putFailure = null;
|
||||
comeBack();
|
||||
await until(() => h.push.puts.length == 2, '다시 등록');
|
||||
await until(() => push().registered, '등록 상태');
|
||||
});
|
||||
});
|
||||
|
||||
group('끝나는 때 (로그아웃 · 세션 끝 · 제한)', () {
|
||||
test(
|
||||
'로그아웃 — 토큰을 지운 뒤 알림창을 지우고 FCM 등록을 푼다. 알림 서버의 DELETE 는 부르지 않는다',
|
||||
() async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
bool? hadSession;
|
||||
h.messaging.onUnregister = () => hadSession = h.store.hasSession;
|
||||
final int clearsBefore = h.notifications.clears;
|
||||
|
||||
await container.read(sessionProvider.notifier).signOut();
|
||||
await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기');
|
||||
|
||||
expect(hadSession, isFalse);
|
||||
expect(h.notifications.clears, greaterThan(clearsBefore));
|
||||
expect(h.push.deletes, isEmpty);
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
|
||||
await until(() => !h.prefs.values.pendingUnregister, '풀기 표시 지움');
|
||||
expect(h.prefs.values.lastFid, isNull);
|
||||
expect(h.prefs.values.serverRegistered, isFalse);
|
||||
expect(push().registered, isFalse);
|
||||
expect(push().sessionReady, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('세션이 끝나면(AUTH_002) 알림창을 지우고 FCM 등록을 푼다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
final int clearsBefore = h.notifications.clears;
|
||||
|
||||
await h.store.clear();
|
||||
container.read(sessionEventsProvider).emit(const SessionEnded());
|
||||
await until(() => h.messaging.unregisterCalls == 1, 'FCM 등록 풀기');
|
||||
|
||||
expect(h.notifications.clears, greaterThan(clearsBefore));
|
||||
expect(h.push.deletes, isEmpty);
|
||||
expect(
|
||||
container.read(sessionProvider).value,
|
||||
const SignedOut(reason: SignOutReason.expired),
|
||||
);
|
||||
});
|
||||
|
||||
test('FCM 등록을 풀지 못하면 표시를 남기고, 로그아웃 상태로 다음에 앞으로 올 때 다시 푼다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
h.messaging.unregisterError = PlatformException(code: 'test');
|
||||
|
||||
await container.read(sessionProvider.notifier).signOut();
|
||||
await until(() => h.messaging.unregisterCalls == 1, '첫 풀기');
|
||||
await idle();
|
||||
expect(h.prefs.values.pendingUnregister, isTrue);
|
||||
|
||||
h.messaging.unregisterError = null;
|
||||
comeBack();
|
||||
await until(() => h.messaging.unregisterCalls == 2, '다시 풀기');
|
||||
await until(() => !h.prefs.values.pendingUnregister, '표시 지움');
|
||||
});
|
||||
|
||||
test('풀지 못한 등록이 남은 채 로그아웃 상태로 앱을 띄우면 바로 다시 푼다', () async {
|
||||
h.prefs.values = const PushPreferenceValues(
|
||||
pendingUnregister: true,
|
||||
lastFid: 'fid-old',
|
||||
);
|
||||
|
||||
await start(refreshToken: null);
|
||||
await until(() => h.messaging.unregisterCalls == 1, '다시 풀기');
|
||||
await until(() => !h.prefs.values.pendingUnregister, '표시 지움');
|
||||
expect(h.messaging.registerCalls, 0);
|
||||
});
|
||||
|
||||
test('같은 휴대폰에 다른 직원 — 로그아웃 뒤 새 로그인은 새 등록 소식을 기다려 새 세션으로 PUT 한다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '앞 직원 등록');
|
||||
await container.read(sessionProvider.notifier).signOut();
|
||||
await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기');
|
||||
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appLogin,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'status': 'signedIn',
|
||||
'email': 'doyun.lee@arasystem.it.kr',
|
||||
'name': '이도윤',
|
||||
'restriction': null,
|
||||
'twoFactorDeadline': null,
|
||||
'tokens': tokensJson(3, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
await container
|
||||
.read(sessionProvider.notifier)
|
||||
.signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw');
|
||||
await until(() => h.push.puts.length == 2, '새 직원 등록');
|
||||
|
||||
expect(h.push.puts.last.bearer, 'access-3');
|
||||
expect(h.messaging.calls, <String>['register', 'unregister', 'register']);
|
||||
});
|
||||
|
||||
test('풀기가 실패해도 같은 실행의 다음 로그인은 FCM 에 다시 등록하고 새 세션으로 PUT 한다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '앞 직원 등록');
|
||||
// 끊긴 망에서 로그아웃 — FCM 등록 풀기가 실패한다(서버 logout 도 닿지 못했을 수 있다)
|
||||
h.messaging.unregisterError = PlatformException(code: 'offline');
|
||||
await container.read(sessionProvider.notifier).signOut();
|
||||
await until(() => h.messaging.unregisterCalls == 1, '앞 직원 풀기(실패)');
|
||||
await idle();
|
||||
expect(h.prefs.values.pendingUnregister, isTrue);
|
||||
h.messaging.unregisterError = null;
|
||||
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appLogin,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'status': 'signedIn',
|
||||
'email': 'doyun.lee@arasystem.it.kr',
|
||||
'name': '이도윤',
|
||||
'restriction': null,
|
||||
'twoFactorDeadline': null,
|
||||
'tokens': tokensJson(3, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
await container
|
||||
.read(sessionProvider.notifier)
|
||||
.signIn(email: 'doyun.lee@arasystem.it.kr', password: 'pw');
|
||||
await until(() => h.push.puts.length == 2, '새 직원 등록');
|
||||
|
||||
expect(h.messaging.calls, <String>['register', 'unregister', 'register']);
|
||||
expect(h.push.puts.last.bearer, 'access-3');
|
||||
expect(
|
||||
h.push.puts.last.body['fid'],
|
||||
h.push.puts.first.body['fid'],
|
||||
reason: '같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다',
|
||||
);
|
||||
await until(() => !h.prefs.values.pendingUnregister, '이어진 등록이 표시를 지움');
|
||||
});
|
||||
|
||||
test('등록이 끝나기 전에 로그아웃하고 풀기가 실패하면 "풀지 못한 등록" 표시가 남는다', () async {
|
||||
h.messaging.registerGate = Completer<void>();
|
||||
await start();
|
||||
await until(() => h.messaging.registerCalls == 1, 'FCM 등록 시작');
|
||||
|
||||
// 등록이 끝나기 전에 로그아웃 — 풀기는 등록 뒤에 줄을 서고, 끊긴 망이라 실패한다
|
||||
h.messaging.unregisterError = PlatformException(code: 'offline');
|
||||
await container.read(sessionProvider.notifier).signOut();
|
||||
await idle();
|
||||
expect(h.prefs.values.pendingUnregister, isTrue);
|
||||
h.messaging.registerGate!.complete();
|
||||
await until(() => h.messaging.unregisterCalls == 1, '풀기(실패)');
|
||||
await idle();
|
||||
|
||||
expect(
|
||||
h.prefs.values.pendingUnregister,
|
||||
isTrue,
|
||||
reason: '끝난 등록이 표시를 지우지 않는다 — 다음 실행에 다시 푼다',
|
||||
);
|
||||
expect(h.push.puts, isEmpty, reason: '풀리는 등록의 FID 로 올리지 않는다');
|
||||
});
|
||||
|
||||
test('세션에 제한이 생기면(갱신 응답) 알림 서버를 부르지 않고, 풀리면 다시 PUT 한다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
await until(() => h.push.seen.isNotEmpty, '앱을 띄운 뒤의 봤음');
|
||||
final SessionEventBus bus = container.read(sessionEventsProvider);
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
|
||||
await until(() => !push().sessionReady, '멈춤');
|
||||
h.clock.advance(const Duration(minutes: 1));
|
||||
final int seenBefore = h.push.seen.length;
|
||||
comeBack();
|
||||
await idle();
|
||||
expect(h.push.seen, hasLength(seenBefore));
|
||||
expect(h.push.puts, hasLength(1));
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson()));
|
||||
await until(() => h.push.puts.length == 2, '제한이 풀려 다시 등록');
|
||||
});
|
||||
});
|
||||
|
||||
group('앞으로 옴 · 받은 알림 · 누른 알림', () {
|
||||
test('앱을 띄우면(앞에 있음) 알림창을 지우고, 등록 뒤 봤음을 보낸다', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
await until(() => h.push.seen.isNotEmpty, '봤음');
|
||||
|
||||
expect(h.notifications.clears, greaterThanOrEqualTo(1));
|
||||
expect(h.push.seen.single.extra[kSilentExtraKey], isTrue);
|
||||
});
|
||||
|
||||
test('앞으로 오면 봤음을 보낸다 — 30초가 지났으면 바로', () async {
|
||||
await start();
|
||||
await until(() => h.push.seen.isNotEmpty, '처음 봤음');
|
||||
|
||||
h.clock.advance(const Duration(seconds: 31));
|
||||
comeBack();
|
||||
await until(() => h.push.seen.length == 2, '앞으로 옴');
|
||||
});
|
||||
|
||||
test('앞에 있을 때 온 새 메일 알림 — 메일 모듈에 알리고 봤음을 보낸다. 세션 끝 알림은 알리기만', () async {
|
||||
await start();
|
||||
await until(() => h.push.seen.isNotEmpty, '처음 봤음');
|
||||
h.clock.advance(const Duration(seconds: 31));
|
||||
|
||||
h.messaging.receive(kNewMailPush);
|
||||
await until(() => h.push.seen.length == 2, '받은 알림 봤음');
|
||||
final PushEvent? event = container.read(pushEventsProvider);
|
||||
expect(event?.opened, isFalse);
|
||||
expect(event?.message.isNewMail, isTrue);
|
||||
|
||||
h.clock.advance(const Duration(seconds: 31));
|
||||
h.messaging.receive(kSessionEndedPush);
|
||||
await idle();
|
||||
expect(h.push.seen, hasLength(2));
|
||||
expect(container.read(pushEventsProvider)?.message.isNewMail, isFalse);
|
||||
});
|
||||
|
||||
test('누른 알림은 라우터가 가져갈 때까지 값으로 든다 — 꺼져 있다가 뜬 알림 · 뒤에서 누른 알림', () async {
|
||||
h.messaging.initial = kNewMailPush;
|
||||
await start();
|
||||
await until(
|
||||
() => container.read(pushEventsProvider)?.opened ?? false,
|
||||
'꺼져 있다가 뜬 알림',
|
||||
);
|
||||
final PushEvents events = container.read(pushEventsProvider.notifier);
|
||||
expect(events.takeOpened(), kNewMailPush);
|
||||
expect(events.takeOpened(), isNull);
|
||||
|
||||
h.messaging.tap(kNewMailPush);
|
||||
await until(
|
||||
() => (container.read(pushEventsProvider)?.seq ?? 0) == 2,
|
||||
'뒤에서 누른 알림',
|
||||
);
|
||||
expect(events.takeOpened(), kNewMailPush);
|
||||
});
|
||||
});
|
||||
|
||||
group('봤음 — 30초 간격 · 건너뛰면 끝에 한 번 (시험 시계)', () {
|
||||
/// 시험 시계를 넘기며 조건이 될 때까지 — 네트워크(가짜 서버)는 시험 시계 안에서 차례가 넘어가야 끝난다
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() condition,
|
||||
String reason,
|
||||
) async {
|
||||
for (int turn = 0; turn < 300 && !condition(); turn++) {
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
expect(condition(), isTrue, reason: reason);
|
||||
}
|
||||
|
||||
/// 손 시계와 시험 시계를 함께 넘긴다(간격은 손 시계로 재고 끝은 타이머로 기다린다)
|
||||
Future<void> elapse(WidgetTester tester, Duration duration) async {
|
||||
h.clock.advance(duration);
|
||||
await tester.pump(duration);
|
||||
}
|
||||
|
||||
/// 앱이 뒤로 간다 · 앞으로 온다(따로)
|
||||
void foreground(bool value) =>
|
||||
(container.read(appForegroundProvider.notifier) as FakeForeground).set(
|
||||
value,
|
||||
);
|
||||
|
||||
testWidgets('앞에 있는 동안 30초 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
|
||||
await elapse(tester, const Duration(seconds: 10));
|
||||
comeBack();
|
||||
await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기');
|
||||
await elapse(tester, const Duration(seconds: 5));
|
||||
h.messaging.receive(kNewMailPush);
|
||||
await elapse(tester, const Duration(seconds: 14));
|
||||
expect(h.push.seen, hasLength(1), reason: '30초 안이라 건너뛴다');
|
||||
|
||||
await elapse(tester, const Duration(seconds: 1));
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '간격 끝에 한 번');
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2), reason: '건너뛴 것이 여럿이어도 끝에 한 번');
|
||||
});
|
||||
|
||||
testWidgets('간격 끝을 기다리는 동안 뒤로 가면 그 자리에서 한 번 보내고, 끝에는 보내지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
|
||||
await elapse(tester, const Duration(seconds: 10));
|
||||
comeBack();
|
||||
await pumpUntil(tester, () => h.notifications.statusCalls >= 2, '다시 읽기');
|
||||
await elapse(tester, const Duration(seconds: 5));
|
||||
expect(h.push.seen, hasLength(1), reason: '30초 안이라 기다린다');
|
||||
|
||||
// 직원이 마지막으로 본 때는 지금이다 — 간격 끝(뒤에 있는 동안)에 보내면 그사이 온 새 메일까지 본 것으로 센다
|
||||
foreground(false);
|
||||
await pumpUntil(tester, () => h.push.seen.length == 2, '뒤로 갈 때 한 번');
|
||||
await elapse(tester, const Duration(seconds: 40));
|
||||
expect(h.push.seen, hasLength(2), reason: '뒤에 있는 동안은 보내지 않는다');
|
||||
|
||||
// 다시 앞으로 오면 그때 규칙대로(마지막으로 보낸 지 30초가 지났다 — 바로)
|
||||
foreground(true);
|
||||
await pumpUntil(tester, () => h.push.seen.length == 3, '다시 앞으로 옴');
|
||||
});
|
||||
|
||||
testWidgets('로그아웃하면 기다리던 봤음을 보내지 않는다', (WidgetTester tester) async {
|
||||
await start();
|
||||
await pumpUntil(tester, () => h.push.seen.length == 1, '앱을 띄운 뒤의 봤음');
|
||||
comeBack();
|
||||
await tester.pump(const Duration(milliseconds: 10));
|
||||
|
||||
unawaited(container.read(sessionProvider.notifier).signOut());
|
||||
await pumpUntil(tester, () => h.messaging.unregisterCalls == 1, '로그아웃');
|
||||
await elapse(tester, const Duration(seconds: 31));
|
||||
|
||||
expect(h.push.seen, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('켜기 · 끄기 (설정 스위치 · 권하기 줄)', () {
|
||||
test('켜기 — 허락 전이면 시스템 창을 띄우고, 허락하면 PUT 이 끝난 뒤 켬을 둔다(알림을 켠 요청)', () async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await start(permission: PushPermission.notAsked);
|
||||
await signedIn();
|
||||
await idle();
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.notAsked);
|
||||
h.push.putGate = Completer<void>();
|
||||
|
||||
final Future<PushTurnOnResult> turning = controller().turnOn();
|
||||
await until(() => h.push.puts.isNotEmpty, 'PUT 보냄');
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.turningOn);
|
||||
expect(h.prefs.values.enabled, isFalse, reason: 'PUT 이 끝나야 켬을 둔다');
|
||||
h.push.putGate!.complete();
|
||||
expect(await turning, PushTurnOnResult.on);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(h.push.puts.single.body['fid'], 'fid-1');
|
||||
final RequestOptions sent = h.server
|
||||
.received('PUT', ApiPaths.pushRegistration)
|
||||
.single;
|
||||
expect(sent.extra[kSilentExtraKey], isNot(isTrue));
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.on);
|
||||
});
|
||||
|
||||
test(
|
||||
'켜기 — [허용 안 함]은 denied, 막혔으면 창 없이 blocked, 채널만 꺼졌으면 channelOff',
|
||||
() async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await start(permission: PushPermission.notAsked);
|
||||
await signedIn();
|
||||
|
||||
h.messaging.answer = PushPermission.denied;
|
||||
expect(await controller().turnOn(), PushTurnOnResult.denied);
|
||||
|
||||
h.messaging
|
||||
..current = PushPermission.blocked
|
||||
..answer = null;
|
||||
final int asked = h.messaging.permissionRequests;
|
||||
expect(await controller().turnOn(), PushTurnOnResult.blocked);
|
||||
expect(h.messaging.permissionRequests, asked);
|
||||
|
||||
h.messaging.current = PushPermission.granted;
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
);
|
||||
expect(await controller().turnOn(), PushTurnOnResult.channelOff);
|
||||
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('켜기 — PUT 이 실패하면 켬을 두지 않고 그 코드를 알린다', () async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await start();
|
||||
await signedIn();
|
||||
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
|
||||
|
||||
expect(await controller().turnOn(), PushTurnOnResult.failed);
|
||||
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.off);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
|
||||
test(
|
||||
'켜기 — 처음 값(켬)인 휴대폰이 허락 전에서 켰는데 PUT 이 실패하면 끔으로 두고 저절로 다시 하지 않는다',
|
||||
() async {
|
||||
await start(permission: PushPermission.notAsked);
|
||||
await signedIn();
|
||||
await until(() => push().loaded && push().sessionReady, '읽기');
|
||||
expect(h.prefs.values.enabled, isTrue, reason: '처음 값');
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.notAsked);
|
||||
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
|
||||
|
||||
expect(await controller().turnOn(), PushTurnOnResult.failed);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.off);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
// 앞으로 와도 조용히 다시 올리지 않는다(직원이 다시 켤 때까지)
|
||||
final int puts = h.push.puts.length;
|
||||
h.push.putFailure = null;
|
||||
comeBack();
|
||||
await idle();
|
||||
expect(h.push.puts, hasLength(puts));
|
||||
},
|
||||
);
|
||||
|
||||
test('켜기 — FCM 등록이 실패해 FID 를 받지 못하면 SYS_001 을 알리고 켜지 않는다', () async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
h.messaging.registerError = PlatformException(code: 'test');
|
||||
await start();
|
||||
await signedIn();
|
||||
|
||||
expect(await controller().turnOn(), PushTurnOnResult.failed);
|
||||
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.notifier.shownCodes, <String>['SYS_001']);
|
||||
});
|
||||
|
||||
test('끄기 — DELETE 가 끝나야 끔을 두고, 실패하면 켬 그대로', () async {
|
||||
await start();
|
||||
await until(() => push().registered, '등록');
|
||||
|
||||
h.push.deleteFailure = FakeResponse.fail(503, 'SYS_001');
|
||||
expect(await controller().turnOff(), PushTurnOffResult.failed);
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.on);
|
||||
expect(h.notifier.shownCodes, <String>['SYS_001']);
|
||||
|
||||
h.push.deleteFailure = null;
|
||||
expect(await controller().turnOff(), PushTurnOffResult.off);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.off);
|
||||
expect(push().registered, isFalse);
|
||||
expect(
|
||||
h.push.deletes.last.extra[kSilentExtraKey],
|
||||
isNot(isTrue),
|
||||
reason: '직원이 끈 것은 실패를 알린다',
|
||||
);
|
||||
});
|
||||
|
||||
test('권하기 줄 — 안드로이드 13 이상 · 묻지 않음 · 켬일 때 보이고, 닫으면 이 휴대폰에 남는다', () async {
|
||||
await start(permission: PushPermission.notAsked);
|
||||
await signedIn();
|
||||
await until(() => push().loaded && push().sessionReady, '읽기');
|
||||
|
||||
expect(showsPushOffer(push()), isTrue);
|
||||
controller().dismissOffer();
|
||||
expect(showsPushOffer(push()), isFalse);
|
||||
await until(() => h.prefs.values.offerDismissed, '닫음 기억');
|
||||
});
|
||||
|
||||
test('[설정 열기]는 휴대폰 설정을 연다', () async {
|
||||
await start();
|
||||
expect(await controller().openSettings(), isTrue);
|
||||
h.notifications.opens = false;
|
||||
expect(await controller().openSettings(), isFalse);
|
||||
expect(h.notifications.settingsOpened, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('받을 수 없는 휴대폰(Firebase 를 띄우지 못함)은 아무것도 하지 않는다', () async {
|
||||
h.messaging.available = false;
|
||||
await start();
|
||||
await signedIn();
|
||||
await idle();
|
||||
|
||||
expect(push().deviceSupported, isFalse);
|
||||
expect(pushRowStatusOf(push()), PushRowStatus.unsupported);
|
||||
expect(h.messaging.calls, isEmpty);
|
||||
expect(h.notifications.statusCalls, 0);
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(await controller().turnOn(), PushTurnOnResult.unavailable);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/push/firebase_push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.dart';
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 플랫폼 값 → 앱 값 (권한 · 알림 데이터 · 알림창 채널의 답)
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('알림 권한 (firebase_messaging 17 → PushPermission)', () {
|
||||
test('허락 · 임시 허락은 granted, 묻지 않음은 notAsked, 다시 묻지 못함은 blocked', () {
|
||||
for (final int? sdk in <int?>[null, 30, 33, 35]) {
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.authorized, sdkInt: sdk),
|
||||
PushPermission.granted,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.provisional, sdkInt: sdk),
|
||||
PushPermission.granted,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.notDetermined, sdkInt: sdk),
|
||||
PushPermission.notAsked,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.deniedPermanently, sdkInt: sdk),
|
||||
PushPermission.blocked,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('거절은 안드로이드 13 이상(또는 판 모름)이면 다시 물을 수 있고, 12 이하는 휴대폰 설정에서 끈 것이다', () {
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 33),
|
||||
PushPermission.denied,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.denied),
|
||||
PushPermission.denied,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 32),
|
||||
PushPermission.blocked,
|
||||
);
|
||||
expect(
|
||||
pushPermissionOf(AuthorizationStatus.denied, sdkInt: 24),
|
||||
PushPermission.blocked,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('알림 데이터 → PushMessage (모듈 · 종류만)', () {
|
||||
test('새 메일 알림 — 모듈 메일 · 종류 new-mail · 항목 없음(늘 받은편지함)', () {
|
||||
final PushMessage message = pushMessageOf(
|
||||
const RemoteMessage(
|
||||
data: <String, Object?>{'module': 'mail', 'kind': 'new-mail'},
|
||||
notification: RemoteNotification(title: '새 메일 3통'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(message.target?.module, AppModuleId.mail);
|
||||
expect(message.target?.itemId, isNull);
|
||||
expect(message.kind, PushKinds.newMail);
|
||||
expect(message.isNewMail, isTrue);
|
||||
});
|
||||
|
||||
test('세션 끝 알림은 새 메일이 아니다 · 종류가 없는 메일 알림은 새 메일로 본다 · 모르는 모듈은 갈 곳이 없다', () {
|
||||
expect(
|
||||
PushMessage.fromData(const <String, Object?>{
|
||||
'module': 'mail',
|
||||
'kind': 'session-ended',
|
||||
}).isNewMail,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
PushMessage.fromData(const <String, Object?>{
|
||||
'module': 'mail',
|
||||
}).isNewMail,
|
||||
isTrue,
|
||||
);
|
||||
final PushMessage unknown = PushMessage.fromData(const <String, Object?>{
|
||||
'module': 'chat',
|
||||
'kind': 'new-mail',
|
||||
});
|
||||
expect(unknown.target, isNull);
|
||||
expect(unknown.isNewMail, isFalse);
|
||||
expect(
|
||||
PushMessage.fromData(const <String, Object?>{
|
||||
'module': 'mail',
|
||||
'kind': '',
|
||||
}).kind,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('알림창 채널 (Kotlin NewMailNotifications 와 짝)', () {
|
||||
const MethodChannel channel = PlatformNotificationSystem.channel;
|
||||
final List<String> calls = <String>[];
|
||||
|
||||
tearDown(() {
|
||||
calls.clear();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
void answer(Object? Function(MethodCall call) reply) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall call) async {
|
||||
calls.add(call.method);
|
||||
return reply(call);
|
||||
});
|
||||
}
|
||||
|
||||
test('status 의 칸을 읽고, 모양이 틀린 칸은 켜짐 · 판 모름으로 본다', () async {
|
||||
answer(
|
||||
(_) => <String, Object?>{
|
||||
'appEnabled': true,
|
||||
'channelEnabled': false,
|
||||
'sdkInt': 34,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await const PlatformNotificationSystem().status(),
|
||||
const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
),
|
||||
);
|
||||
|
||||
answer((_) => <String, Object?>{'appEnabled': 'yes', 'sdkInt': '34'});
|
||||
expect(
|
||||
await const PlatformNotificationSystem().status(),
|
||||
const NotificationStatus(appEnabled: true, channelEnabled: true),
|
||||
);
|
||||
expect(calls, <String>['status', 'status']);
|
||||
});
|
||||
|
||||
test('채널이 실패하거나 없으면 막지 않는 쪽 — 상태는 모름(켜짐), 설정 열기는 거짓, 지우기는 조용히', () async {
|
||||
answer((_) => throw PlatformException(code: 'test'));
|
||||
expect(
|
||||
await const PlatformNotificationSystem().status(),
|
||||
NotificationStatus.unknown,
|
||||
);
|
||||
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
|
||||
await const PlatformNotificationSystem().clearNewMail();
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
expect(
|
||||
await const PlatformNotificationSystem().status(),
|
||||
NotificationStatus.unknown,
|
||||
);
|
||||
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
|
||||
});
|
||||
|
||||
test('openSettings 는 opened 일 때만 참, clearNewMail 은 그 이름으로 부른다', () async {
|
||||
answer(
|
||||
(MethodCall call) => call.method == 'openSettings' ? 'opened' : null,
|
||||
);
|
||||
expect(await const PlatformNotificationSystem().openSettings(), isTrue);
|
||||
await const PlatformNotificationSystem().clearNewMail();
|
||||
expect(calls, <String>['openSettings', 'clearNewMail']);
|
||||
|
||||
answer((_) => 'failed');
|
||||
expect(await const PlatformNotificationSystem().openSettings(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('받을 수 없는 휴대폰의 알림 받기는 비어 있다', () async {
|
||||
const UnavailablePushMessaging messaging = UnavailablePushMessaging();
|
||||
expect(messaging.isAvailable, isFalse);
|
||||
expect(await messaging.permission(), PushPermission.blocked);
|
||||
expect(await messaging.initialOpen(), isNull);
|
||||
expect(await messaging.onRegistered.isEmpty, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/api/request_flags.dart';
|
||||
import 'package:arasystem_app/core/push/push_registration.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
|
||||
/// 새 메일 알림 등록 API — 계약 1절 "앱 새 메일 알림 · 앱 설정"
|
||||
void main() {
|
||||
late ApiHarness api;
|
||||
late PushRegistrationRepository repository;
|
||||
|
||||
setUp(() async {
|
||||
api = ApiHarness();
|
||||
await api.prepare();
|
||||
await api.signIn();
|
||||
repository = PushRegistrationRepository(api.dio);
|
||||
});
|
||||
|
||||
test(
|
||||
'등록 — PUT /push-registrations/current { fid, platform: android, appVersion } · Bearer',
|
||||
() async {
|
||||
api.server.on(
|
||||
'PUT',
|
||||
ApiPaths.pushRegistration,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'updatedAt': '2026-11-07T05:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
await repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true);
|
||||
|
||||
final RequestOptions sent = api.server.requests.single;
|
||||
expect(sent.method, 'PUT');
|
||||
expect(sent.path, '/push-registrations/current');
|
||||
expect(sent.data, <String, Object?>{
|
||||
'fid': 'fid-1',
|
||||
'platform': 'android',
|
||||
'appVersion': '1.0.0',
|
||||
});
|
||||
expect(bearerTokenOf(sent), 'access-1');
|
||||
expect(sent.extra[kSilentExtraKey], isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('저절로 하는 맞추기는 알리지 않고, 직원이 켜고 끈 것은 알린다', () async {
|
||||
api.server
|
||||
..on(
|
||||
'PUT',
|
||||
ApiPaths.pushRegistration,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
)
|
||||
..on(
|
||||
'DELETE',
|
||||
ApiPaths.pushRegistration,
|
||||
(_) => FakeResponse.fail(503, 'SYS_001'),
|
||||
);
|
||||
|
||||
await errorOf(
|
||||
repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: true),
|
||||
);
|
||||
await errorOf(repository.delete(silent: true));
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
|
||||
await errorOf(
|
||||
repository.put(fid: 'fid-1', appVersion: '1.0.0', silent: false),
|
||||
);
|
||||
await errorOf(repository.delete(silent: false));
|
||||
expect(api.notifier.shownCodes, <String>['MAIL_001', 'SYS_001']);
|
||||
});
|
||||
|
||||
test('지우기 — DELETE /push-registrations/current (204)', () async {
|
||||
api.server.on(
|
||||
'DELETE',
|
||||
ApiPaths.pushRegistration,
|
||||
(_) => const FakeResponse(204, null, rawBody: ''),
|
||||
);
|
||||
|
||||
await repository.delete(silent: true);
|
||||
|
||||
expect(api.server.requests.single.path, '/push-registrations/current');
|
||||
expect(api.server.requests.single.method, 'DELETE');
|
||||
});
|
||||
|
||||
test(
|
||||
'봤음 — DELETE /push-registrations/current/unseen (204) · 알리지 않는다',
|
||||
() async {
|
||||
api.server.on(
|
||||
'DELETE',
|
||||
ApiPaths.pushRegistrationUnseen,
|
||||
(_) => const FakeResponse(204, null, rawBody: ''),
|
||||
);
|
||||
|
||||
await repository.markSeen();
|
||||
|
||||
final RequestOptions sent = api.server.requests.single;
|
||||
expect(sent.path, '/push-registrations/current/unseen');
|
||||
expect(sent.extra[kSilentExtraKey], isTrue);
|
||||
|
||||
api.server.on(
|
||||
'DELETE',
|
||||
ApiPaths.pushRegistrationUnseen,
|
||||
(_) => FakeResponse.fail(503, 'SYS_001'),
|
||||
);
|
||||
await errorOf(repository.markSeen());
|
||||
expect(api.notifier.shownCodes, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:arasystem_app/core/push/push_messaging.dart';
|
||||
import 'package:arasystem_app/core/push/push_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 켜짐 — 받을 수 있는 휴대폰 · 세션 · 허락 · 채널 켜짐 · 켬 (안드로이드 14)
|
||||
const PushState _on = PushState(
|
||||
loaded: true,
|
||||
deviceSupported: true,
|
||||
server: PushServer.available,
|
||||
sessionReady: true,
|
||||
permission: PushPermission.granted,
|
||||
sdkInt: 34,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('설정 › 새 메일 알림 줄의 모습 (보드 App1Push ④ — 앱 디자인 README 의 표)', () {
|
||||
test('표 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔', () {
|
||||
final List<(PushState, PushRowStatus)> table =
|
||||
<(PushState, PushRowStatus)>[
|
||||
(_on, PushRowStatus.on),
|
||||
(_on.copyWith(enabled: false), PushRowStatus.off),
|
||||
(
|
||||
_on.copyWith(permission: PushPermission.notAsked),
|
||||
PushRowStatus.notAsked,
|
||||
),
|
||||
(
|
||||
_on.copyWith(permission: PushPermission.denied),
|
||||
PushRowStatus.notAsked,
|
||||
),
|
||||
(
|
||||
_on.copyWith(permission: PushPermission.notAsked, enabled: false),
|
||||
PushRowStatus.notAsked,
|
||||
),
|
||||
(
|
||||
_on.copyWith(permission: PushPermission.blocked),
|
||||
PushRowStatus.blocked,
|
||||
),
|
||||
(
|
||||
_on.copyWith(permission: PushPermission.blocked, enabled: false),
|
||||
PushRowStatus.blocked,
|
||||
),
|
||||
(_on.copyWith(channelEnabled: false), PushRowStatus.channelOff),
|
||||
(
|
||||
_on.copyWith(channelEnabled: false, enabled: false),
|
||||
PushRowStatus.channelOff,
|
||||
),
|
||||
(
|
||||
_on.copyWith(server: PushServer.unavailable),
|
||||
PushRowStatus.unavailable,
|
||||
),
|
||||
(_on.copyWith(server: PushServer.unknown), PushRowStatus.on),
|
||||
(const PushState(loaded: true), PushRowStatus.unsupported),
|
||||
(
|
||||
_on.copyWith(change: PushChange.turningOn, enabled: false),
|
||||
PushRowStatus.turningOn,
|
||||
),
|
||||
(
|
||||
_on.copyWith(change: PushChange.turningOff),
|
||||
PushRowStatus.turningOff,
|
||||
),
|
||||
(
|
||||
const PushState(deviceSupported: true, sessionReady: true),
|
||||
PushRowStatus.loading,
|
||||
),
|
||||
(
|
||||
const PushState(loaded: true, deviceSupported: true),
|
||||
PushRowStatus.loading,
|
||||
),
|
||||
];
|
||||
for (final (PushState state, PushRowStatus expected) in table) {
|
||||
expect(pushRowStatusOf(state), expected, reason: '$state');
|
||||
}
|
||||
});
|
||||
|
||||
test('켜는 중 표시를 지우면(clearChange) 원래 모습으로 돌아온다', () {
|
||||
final PushState busy = _on.copyWith(change: PushChange.turningOn);
|
||||
expect(
|
||||
pushRowStatusOf(busy.copyWith(clearChange: true)),
|
||||
PushRowStatus.on,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('받은편지함 위 권하기 줄', () {
|
||||
final PushState asking = _on.copyWith(permission: PushPermission.notAsked);
|
||||
|
||||
test('안드로이드 13 이상 · 묻지 않음 · 켬 · 서버 · 휴대폰 · 세션 · 닫지 않음이면 보인다', () {
|
||||
expect(showsPushOffer(asking), isTrue);
|
||||
expect(showsPushOffer(asking.copyWith(sdkInt: 33)), isTrue);
|
||||
});
|
||||
|
||||
test('하나라도 아니면 보이지 않는다', () {
|
||||
expect(showsPushOffer(asking.copyWith(sdkInt: 32)), isFalse);
|
||||
expect(showsPushOffer(const PushState(loaded: true)), isFalse);
|
||||
expect(showsPushOffer(_on), isFalse, reason: '이미 허락');
|
||||
expect(
|
||||
showsPushOffer(asking.copyWith(permission: PushPermission.denied)),
|
||||
isFalse,
|
||||
);
|
||||
expect(showsPushOffer(asking.copyWith(enabled: false)), isFalse);
|
||||
expect(showsPushOffer(asking.copyWith(offerDismissed: true)), isFalse);
|
||||
expect(
|
||||
showsPushOffer(asking.copyWith(server: PushServer.unavailable)),
|
||||
isFalse,
|
||||
);
|
||||
expect(showsPushOffer(asking.copyWith(sessionReady: false)), isFalse);
|
||||
expect(
|
||||
showsPushOffer(asking.copyWith(change: PushChange.turningOn)),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
showsPushOffer(
|
||||
const PushState(
|
||||
deviceSupported: true,
|
||||
sessionReady: true,
|
||||
permission: PushPermission.notAsked,
|
||||
sdkInt: 34,
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
reason: '값을 읽기 전',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('보내야 하나 · 맞출 수 있나', () {
|
||||
test('켬 ∧ 허락 ∧ 새 메일 채널일 때만 받고 싶다', () {
|
||||
expect(pushWanted(_on), isTrue);
|
||||
expect(pushWanted(_on.copyWith(enabled: false)), isFalse);
|
||||
expect(
|
||||
pushWanted(_on.copyWith(permission: PushPermission.notAsked)),
|
||||
isFalse,
|
||||
);
|
||||
expect(pushWanted(_on.copyWith(channelEnabled: false)), isFalse);
|
||||
});
|
||||
|
||||
test('읽었고 받을 수 있는 휴대폰 · 서버(모르면 된다) · 세션일 때만 맞춘다', () {
|
||||
expect(pushReachable(_on), isTrue);
|
||||
expect(pushReachable(_on.copyWith(server: PushServer.unknown)), isTrue);
|
||||
expect(
|
||||
pushReachable(_on.copyWith(server: PushServer.unavailable)),
|
||||
isFalse,
|
||||
);
|
||||
expect(pushReachable(_on.copyWith(sessionReady: false)), isFalse);
|
||||
expect(pushReachable(_on.copyWith(loaded: false)), isFalse);
|
||||
expect(pushReachable(const PushState(loaded: true)), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -114,7 +114,7 @@ void main() {
|
||||
expect(resolveSessionRedirect(_signedIn(), '/files/f1'), homeLocation);
|
||||
expect(resolveSessionRedirect(_signedIn(), '/nowhere'), homeLocation);
|
||||
expect(resolveSessionRedirect(_signedIn(), '/'), homeLocation);
|
||||
expect(resolveSessionRedirect(_signedIn(), '/settings/about'), isNull);
|
||||
expect(resolveSessionRedirect(_signedIn(), '/settings/licenses'), isNull);
|
||||
});
|
||||
|
||||
test('홈은 넘겨받은 모듈 목록을 따른다 — 메일이 꺼졌으면 켜진 첫 모듈로', () {
|
||||
@@ -152,4 +152,70 @@ void main() {
|
||||
expect(SessionRestriction.parse('unknown'), SessionRestriction.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('앱을 업데이트해야 할 때 (최소 판)', () {
|
||||
test('세션과 상관없이 어느 화면에서든 업데이트 화면 — 로그인 전 화면 · 되살리는 중 · 제한 단계도', () {
|
||||
final List<(AsyncValue<SessionState>, String)> cases =
|
||||
<(AsyncValue<SessionState>, String)>[
|
||||
(const AsyncLoading<SessionState>(), AppRoutes.start),
|
||||
(const AsyncData<SessionState>(SignedOut()), AppRoutes.login),
|
||||
(
|
||||
const AsyncData<SessionState>(
|
||||
CodeRequired('ara.kim@arasystem.it.kr'),
|
||||
),
|
||||
AppRoutes.loginCode,
|
||||
),
|
||||
(
|
||||
_signedIn(SessionRestriction.passwordChange),
|
||||
AppRoutes.passwordChange,
|
||||
),
|
||||
(_signedIn(), homeLocation),
|
||||
(_signedIn(), '/mail/compose'),
|
||||
(_signedIn(), '/settings/licenses'),
|
||||
];
|
||||
for (final (AsyncValue<SessionState> session, String location) in cases) {
|
||||
expect(
|
||||
resolveSessionRedirect(session, location, updateRequired: true),
|
||||
AppRoutes.update,
|
||||
reason: location,
|
||||
);
|
||||
expect(
|
||||
resolveSessionRedirect(
|
||||
session,
|
||||
AppRoutes.update,
|
||||
updateRequired: true,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('풀리면 업데이트 화면은 세션이 고른 화면으로 돌아간다', () {
|
||||
expect(
|
||||
resolveSessionRedirect(_signedIn(), AppRoutes.update),
|
||||
homeLocation,
|
||||
);
|
||||
expect(
|
||||
resolveSessionRedirect(
|
||||
const AsyncData<SessionState>(SignedOut()),
|
||||
AppRoutes.update,
|
||||
),
|
||||
AppRoutes.login,
|
||||
);
|
||||
expect(
|
||||
resolveSessionRedirect(
|
||||
const AsyncLoading<SessionState>(),
|
||||
AppRoutes.update,
|
||||
),
|
||||
AppRoutes.start,
|
||||
);
|
||||
expect(
|
||||
resolveSessionRedirect(
|
||||
_signedIn(SessionRestriction.twoFactorSetup),
|
||||
AppRoutes.update,
|
||||
),
|
||||
AppRoutes.twoFactorSetup,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/constants/app_info_provider.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.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/app_update/presentation/pages/app_update_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
|
||||
/// 시험 앱의 패키지 이름
|
||||
const String _package = 'kr.it.arasystem.app';
|
||||
|
||||
/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱(1.0.0)이 서버의 최소 판(2.0.0)보다 낮다
|
||||
void main() {
|
||||
late LoginHarness h;
|
||||
|
||||
/// 다른 앱으로 넘긴 주소 · 방식 — [opens] 가 답한다(주소마다)
|
||||
final List<({Uri url, LaunchMode mode})> launched =
|
||||
<({Uri url, LaunchMode mode})>[];
|
||||
late bool Function(Uri url) opens;
|
||||
|
||||
Future<bool> launch(Uri url, LaunchMode mode) async {
|
||||
launched.add((url: url, mode: mode));
|
||||
return opens(url);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
h = LoginHarness();
|
||||
launched.clear();
|
||||
opens = (Uri _) => true;
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.appConfig,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '2.0.0'},
|
||||
'pushAvailable': true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> pump(WidgetTester tester, {String? packageName = _package}) =>
|
||||
h.pump(
|
||||
tester,
|
||||
overrides: <Override>[
|
||||
appVersionProvider.overrideWithValue('1.0.0'),
|
||||
appPackageNameProvider.overrideWithValue(packageName),
|
||||
appStoreLauncherProvider.overrideWithValue(
|
||||
AppStoreLauncher(launch: launch),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Finder storeButton() =>
|
||||
find.widgetWithText(FilledButton, AppUpdateTexts.openStore);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
testWidgets('제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄(연락처)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.supportContact('정보보안팀 02-1234-5678');
|
||||
await pump(tester);
|
||||
|
||||
expect(find.byType(AppUpdatePage), findsOneWidget);
|
||||
expect(find.text(ScreenTitles.update), findsOneWidget);
|
||||
expect(findCopy(AppUpdateTexts.message), findsOneWidget);
|
||||
expect(storeButton(), findsOneWidget);
|
||||
expect(find.text(AppUpdateTexts.currentVersion('1.0.0')), findsOneWidget);
|
||||
expect(
|
||||
findCopy('${AppUpdateTexts.supportQuestion}\n정보보안팀 02-1234-5678'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
|
||||
h.supportContact(null);
|
||||
await pump(tester);
|
||||
|
||||
expect(findCopy(AppUpdateTexts.supportFallback), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('[Play 스토어 열기] — Play 스토어 앱부터, 열리지 않으면 웹 주소를 바깥 앱으로', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
opens = (Uri url) => url.scheme != 'market';
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(launched.first.url.toString(), 'market://details?id=$_package');
|
||||
expect(launched.first.mode, LaunchMode.externalNonBrowserApplication);
|
||||
expect(
|
||||
launched.last.url.toString(),
|
||||
'https://play.google.com/store/apps/details?id=$_package',
|
||||
);
|
||||
expect(launched.last.mode, LaunchMode.externalApplication);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Play 스토어 앱이 열리면 웹 주소는 열지 않는다', (WidgetTester tester) async {
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(1));
|
||||
expect(launched.single.url.scheme, 'market');
|
||||
});
|
||||
|
||||
testWidgets('둘 다 열지 못하면 직접 찾아 업데이트해 달라고 알린다', (WidgetTester tester) async {
|
||||
opens = (Uri _) => false;
|
||||
await pump(tester);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, hasLength(2));
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('패키지 이름을 읽지 못했으면 열지 않고 알린다', (WidgetTester tester) async {
|
||||
await pump(tester, packageName: null);
|
||||
|
||||
await tester.tap(storeButton());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(launched, isEmpty);
|
||||
expect(snack(AppUpdateTexts.openStoreFailed), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,17 @@ import 'package:arasystem_app/core/api/error_lexicon.dart';
|
||||
import 'package:arasystem_app/core/api/error_notifier.dart';
|
||||
import 'package:arasystem_app/core/api/session_events.dart';
|
||||
import 'package:arasystem_app/core/api/token_refresher.dart';
|
||||
import 'package:arasystem_app/core/app_config/app_config_controller.dart';
|
||||
import 'package:arasystem_app/core/app_config/app_config_repository.dart';
|
||||
import 'package:arasystem_app/core/constants/login_texts.dart';
|
||||
import 'package:arasystem_app/core/constants/screen_titles.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.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/utils/clock.dart';
|
||||
import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart';
|
||||
@@ -1850,6 +1857,15 @@ bool _allowedProviderValue(Object? value) =>
|
||||
value is MailChange ||
|
||||
value is MailToast ||
|
||||
value is Map<AppModuleId, int> ||
|
||||
// 앱 설정(최소 판 · 알림을 쓸 수 있는지)과 새 메일 알림 — FID · 토큰은 상태에 두지 않는다
|
||||
value is AppConfigState ||
|
||||
value is AppConfigRepository ||
|
||||
value is PushState ||
|
||||
value is PushEvent ||
|
||||
value is PushMessaging ||
|
||||
value is NotificationSystem ||
|
||||
value is PushPreferences ||
|
||||
value is PushRegistrationRepository ||
|
||||
value is bool;
|
||||
|
||||
/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다
|
||||
|
||||
@@ -493,6 +493,78 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('새 메일 알림 (기기 등록)', () {
|
||||
Future<void> signedIn() async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
refreshGives(2);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
expect(await settled(), isA<SignedIn>());
|
||||
await pumpEventsUntil(() => h.push.registered == 1, reason: '등록');
|
||||
}
|
||||
|
||||
test('세션이 끝나면(AUTH_002) 토큰을 지운 뒤 이 기기의 알림을 멈춘다', () async {
|
||||
await signedIn();
|
||||
h.clock.advance(const Duration(hours: 2));
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
h.server.on('GET', '/mailboxes', (_) => FakeResponse.ok(<Object?>[]));
|
||||
|
||||
await errorOf(container.read(dioProvider).get<Object?>('/mailboxes'));
|
||||
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
|
||||
|
||||
expect(current(), const SignedOut(reason: SignOutReason.expired));
|
||||
expect(h.push.hadSessionAtUnregister, isFalse);
|
||||
});
|
||||
|
||||
test('앱이 꺼진 사이 세션이 끝났으면(되살리기의 갱신 거절) 이 기기의 알림도 멈춘다', () async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
|
||||
expect(await settled(), const SignedOut());
|
||||
await pumpEventsUntil(() => h.push.unregistered == 1, reason: '알림 멈춤');
|
||||
expect(h.push.registered, 0);
|
||||
});
|
||||
|
||||
test('쓰던 세션에 제한이 생기면 멈추고(pause), 풀리면 다시 등록한다', () async {
|
||||
await signedIn();
|
||||
final SessionEventBus bus = container.read(sessionEventsProvider);
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson(restriction: 'passwordChange')));
|
||||
await pumpEventsUntil(
|
||||
() => restrictionNow() == SessionRestriction.passwordChange,
|
||||
reason: '제한 반영',
|
||||
);
|
||||
expect(h.push.paused, 1);
|
||||
expect(h.push.unregistered, 0);
|
||||
|
||||
bus.emit(SessionRefreshed(sessionJson()));
|
||||
await pumpEventsUntil(() => h.push.registered == 2, reason: '다시 등록');
|
||||
expect(h.push.paused, 1);
|
||||
});
|
||||
|
||||
test('제한된 채 로그인하면 멈출 것도 등록할 것도 없다', () async {
|
||||
await start();
|
||||
await settled();
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appLogin,
|
||||
(_) => signedInWith(1, restriction: 'twoFactorSetup'),
|
||||
);
|
||||
|
||||
await notifier().signIn(email: 'ara.kim@arasystem.it.kr', password: 'pw');
|
||||
|
||||
expect(h.push.registered, 0);
|
||||
expect(h.push.paused, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('제한 다시 맞추기 — 노티파이어의 갈래', () {
|
||||
Future<void> signedIn() async {
|
||||
await start(refreshToken: 'refresh-1');
|
||||
|
||||
@@ -1098,8 +1098,21 @@ void main() {
|
||||
|
||||
h.router.go(SettingsRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('로그아웃'));
|
||||
final Finder signOut = find.widgetWithText(OutlinedButton, '로그아웃');
|
||||
await tester.ensureVisible(signOut);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(signOut);
|
||||
await tester.pumpAndSettle();
|
||||
// 확인 창의 [로그아웃]
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.text('로그아웃'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// 확인 창이 닫히는 동안 로그아웃한다 — 마지막 그림에서 떼어진 프로바이더는 그다음 차례에 정리된다
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(MailListPage), findsNothing);
|
||||
expect(h.container.exists(mailToastProvider), isFalse);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:arasystem_app/core/push/notification_system.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_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/push_screen_harness.dart';
|
||||
|
||||
/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(줄) · ③(고른 뒤 알림)
|
||||
void main() {
|
||||
late PushScreenHarness h;
|
||||
|
||||
setUp(() {
|
||||
h = PushScreenHarness();
|
||||
// 처음 모습: 안드로이드 14 · 아직 묻지 않음 — 줄이 보인다
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
});
|
||||
|
||||
Finder line() => findCopy(PushTexts.offerMessage);
|
||||
|
||||
Finder enableButton() =>
|
||||
find.widgetWithText(TextButton, PushTexts.offerEnable);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: find.text(text));
|
||||
|
||||
testWidgets('보이는 때 — 받은편지함 위 막대 아래, 글 · [알림 켜기] · [닫기]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsOneWidget);
|
||||
expect(enableButton(), findsOneWidget);
|
||||
expect(find.byTooltip(PushTexts.offerDismiss), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(PushTexts.offerRegion), findsOneWidget);
|
||||
// 목록 위에 고정이다(목록과 함께 흐르지 않는다)
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(PushOfferLine)).dy,
|
||||
lessThanOrEqualTo(tester.getTopLeft(find.text('제목 m1')).dy),
|
||||
);
|
||||
expect(h.messaging.permissionRequests, 0, reason: '누르기 전에는 묻지 않는다');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
group('보이지 않는 때', () {
|
||||
testWidgets('다른 메일함', (WidgetTester tester) async {
|
||||
await h.pump(tester, location: MailRoutes.mailbox(kClientsId));
|
||||
|
||||
expect(find.byType(PushOfferLine), findsNothing, reason: '받은편지함에만 둔다');
|
||||
});
|
||||
|
||||
testWidgets('안드로이드 12 이하(묻지 않고 처음부터 허용)', (WidgetTester tester) async {
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: true,
|
||||
sdkInt: 32,
|
||||
);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('이미 허용', (WidgetTester tester) async {
|
||||
h.messaging.current = PushPermission.granted;
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('닫은 적 있음', (WidgetTester tester) async {
|
||||
h.prefs.values = const PushPreferenceValues(offerDismissed: true);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('이 휴대폰에서 끔', (WidgetTester tester) async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('서버가 보낼 수 없음', (WidgetTester tester) async {
|
||||
h.push.config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': false,
|
||||
};
|
||||
await h.pump(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('메일을 고르는 동안', (WidgetTester tester) async {
|
||||
await h.pump(tester);
|
||||
expect(line(), findsOneWidget);
|
||||
|
||||
await tester.longPress(find.text('제목 m1'));
|
||||
await settle(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] → [허용] — "새 메일 알림을 켰습니다." · 서버에 등록하고 줄은 사라진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(snack(PushTexts.enabled), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] → [허용 안 함] — 나중에 켜는 곳을 알리고 줄은 사라진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.denied;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.deniedLater), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
expect(h.push.puts, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('[알림 켜기] — 휴대폰이 묻지 않고 막혀 있으면 알림 줄에 [설정 열기]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.blocked;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text(PushTexts.blockedNotice), findsOneWidget);
|
||||
expect(line(), findsNothing);
|
||||
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
|
||||
await settle(tester);
|
||||
expect(h.notifications.settingsOpened, 1);
|
||||
});
|
||||
|
||||
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.answer = PushPermission.blocked;
|
||||
h.notifications.opens = false;
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(enableButton());
|
||||
await settle(tester);
|
||||
await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings));
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('[닫기] — 줄이 사라지고 이 휴대폰에서 다시 보이지 않는다(묻지도 않는다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
|
||||
await tester.tap(find.byTooltip(PushTexts.offerDismiss));
|
||||
await settle(tester);
|
||||
|
||||
expect(line(), findsNothing);
|
||||
expect(h.prefs.values.offerDismissed, isTrue);
|
||||
expect(h.messaging.permissionRequests, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/push/notification_system.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_texts.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/push_screen_harness.dart';
|
||||
|
||||
/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄)
|
||||
void main() {
|
||||
late PushScreenHarness h;
|
||||
|
||||
setUp(() => h = PushScreenHarness());
|
||||
|
||||
/// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답한다(연락처 없음)
|
||||
void supportContact(String? text) {
|
||||
h.server.on(
|
||||
'GET',
|
||||
ApiPaths.supportContact,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'text': text}),
|
||||
);
|
||||
}
|
||||
|
||||
Finder dialog() => find.byType(AlertDialog);
|
||||
|
||||
Finder inDialog(String text) =>
|
||||
find.descendant(of: dialog(), matching: find.text(text));
|
||||
|
||||
Finder signOutButton() =>
|
||||
find.widgetWithText(OutlinedButton, SettingsTexts.signOut);
|
||||
|
||||
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(finder);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
group('설정 한 화면', () {
|
||||
testWidgets('절 — 내 정보(이름 · 주소) · 알림 · 앱 정보(판) · 문의(연락처) · [로그아웃]', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
supportContact('정보보안팀 02-1234-5678');
|
||||
await h.pump(tester);
|
||||
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(h.app.location(), '/settings');
|
||||
for (final String title in <String>[
|
||||
SettingsTexts.account,
|
||||
SettingsTexts.notifications,
|
||||
SettingsTexts.about,
|
||||
SettingsTexts.support,
|
||||
]) {
|
||||
expect(find.text(title), findsOneWidget, reason: title);
|
||||
}
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(find.text(kTestEmail), findsOneWidget);
|
||||
expect(find.text(PushTexts.rowLabel), findsOneWidget);
|
||||
expect(find.text(SettingsTexts.appVersion), findsOneWidget);
|
||||
expect(find.text(kPushScreenAppVersion), findsOneWidget);
|
||||
expect(find.text(SettingsTexts.licenses), findsOneWidget);
|
||||
expect(
|
||||
findCopy('${SettingsTexts.supportLead}\n정보보안팀 02-1234-5678'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(signOutButton(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async {
|
||||
supportContact(null);
|
||||
await h.pump(tester);
|
||||
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(findCopy(SettingsTexts.supportFallback), findsOneWidget);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('[오픈소스 라이선스]는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.ensureVisible(find.text(SettingsTexts.licenses));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(SettingsTexts.licenses));
|
||||
// 라이선스 화면은 읽는 동안 둥근 표시가 돈다 — 화면 넘김만 기다린다
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.byType(LicensePage), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('[로그아웃] — 확인 창부터', () {
|
||||
testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, signOutButton());
|
||||
|
||||
expect(inDialog(SettingsTexts.signOutTitle), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: dialog(),
|
||||
matching: findCopy(SettingsTexts.signOutMessage),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final TextButton cancel = tester.widget<TextButton>(
|
||||
find.widgetWithText(TextButton, SettingsTexts.cancel),
|
||||
);
|
||||
expect(cancel.autofocus, isTrue);
|
||||
|
||||
await tester.tap(inDialog(SettingsTexts.cancel));
|
||||
await settle(tester);
|
||||
|
||||
expect(dialog(), findsNothing);
|
||||
expect(h.app.location(), '/settings');
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), isEmpty);
|
||||
expect(h.messaging.unregisterCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('[로그아웃]을 고르면 로그인 화면 — 이 휴대폰의 알림 등록도 푼다(서버 줄은 서버가 지운다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await h.pump(tester);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tapVisible(tester, signOutButton());
|
||||
await tester.tap(inDialog(SettingsTexts.signOut));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1));
|
||||
expect(h.messaging.unregisterCalls, 1);
|
||||
expect(h.push.deletes, isEmpty, reason: '로그아웃은 DELETE 를 부르지 않는다');
|
||||
expect(h.notifications.clears, greaterThan(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('설정 › 새 메일 알림 줄', () {
|
||||
Finder switchFinder() => find.byType(Switch);
|
||||
|
||||
Switch switchOf(WidgetTester tester) =>
|
||||
tester.widget<Switch>(switchFinder());
|
||||
|
||||
Finder openSettingsButton() =>
|
||||
find.widgetWithText(TextButton, PushTexts.openSettings);
|
||||
|
||||
Finder snack(String text) =>
|
||||
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
|
||||
|
||||
void lifecycle(WidgetTester tester, List<AppLifecycleState> states) {
|
||||
for (final AppLifecycleState state in states) {
|
||||
tester.binding.handleAppLifecycleStateChanged(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다)
|
||||
Future<void> visitPhoneSettings(WidgetTester tester) async {
|
||||
lifecycle(tester, const <AppLifecycleState>[
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
]);
|
||||
await tester.pump();
|
||||
lifecycle(tester, const <AppLifecycleState>[
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.resumed,
|
||||
]);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('켜짐 — 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(
|
||||
tester.getSemantics(switchFinder()),
|
||||
isSemantics(
|
||||
label: PushTexts.rowLabel,
|
||||
hint: PushTexts.rowDescription,
|
||||
hasToggledState: true,
|
||||
isToggled: true,
|
||||
hasEnabledState: true,
|
||||
isEnabled: true,
|
||||
),
|
||||
);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('끄기 — 끄는 중(둥근 표시)에는 다시 받지 않고, 서버 등록을 지운 뒤 꺼진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
h.push.deleteGate = Completer<void>();
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(h.push.deletes, hasLength(1));
|
||||
expect(switchOf(tester).value, isFalse, reason: '바꿀 값 쪽에 둔다');
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.bySemanticsLabel(PushTexts.turningOff), findsOneWidget);
|
||||
|
||||
// 끄는 중에 다시 눌러도 겹쳐 보내지 않는다
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(h.push.deletes, hasLength(1));
|
||||
|
||||
h.push.deleteGate!.complete();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('켜기(이미 허용) — 켜는 중을 보이고, 서버에 등록한 뒤 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final SemanticsHandle semantics = tester.ensureSemantics();
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.push.puts, isEmpty, reason: '끈 휴대폰은 등록하지 않는다');
|
||||
h.push.putGate = Completer<void>();
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(h.push.puts, hasLength(1));
|
||||
expect(switchOf(tester).value, isTrue, reason: '바꿀 값 쪽에 둔다');
|
||||
expect(find.bySemanticsLabel(PushTexts.turningOn), findsOneWidget);
|
||||
|
||||
h.push.putGate!.complete();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(h.prefs.values.enabled, isTrue);
|
||||
expect(h.push.puts.single.body['fid'], 'fid-1');
|
||||
expect(h.messaging.permissionRequests, 0, reason: '이미 허용이면 묻지 않는다');
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('켜기가 서버에서 실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구를 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.prefs.values = const PushPreferenceValues(enabled: false);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
h.push.putFailure = FakeResponse.fail(500, 'SYS_001');
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, <String>['SYS_001']);
|
||||
});
|
||||
|
||||
testWidgets('허락 전(처음 값 켬)에서 켜고 [허용]했는데 서버에서 실패하면 꺼진 채로 돌아간다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(h.prefs.values.enabled, isTrue, reason: '처음 값');
|
||||
h.push.putFailure = FakeResponse.fail(503, 'MAIL_001');
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(
|
||||
findCopy(PushTexts.rowDescription),
|
||||
findsOneWidget,
|
||||
reason: '허락은 받았다',
|
||||
);
|
||||
expect(h.prefs.values.enabled, isFalse);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
|
||||
testWidgets('허락 전 — 켜면 휴대폰이 묻고, [허용]이면 켜진다', (WidgetTester tester) async {
|
||||
h.messaging.current = PushPermission.notAsked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNotNull);
|
||||
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('허락 전 — [허용 안 함]이면 꺼진 채 허락 전 글 그대로, 다시 켜면 또 묻는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging
|
||||
..current = PushPermission.notAsked
|
||||
..answer = PushPermission.denied;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
|
||||
expect(h.messaging.permissionRequests, 1);
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(findCopy(PushTexts.rowNotAsked), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(switchFinder());
|
||||
await settle(tester);
|
||||
expect(h.messaging.permissionRequests, 2);
|
||||
});
|
||||
|
||||
testWidgets('휴대폰에서 막힘 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 허용됐으면 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.blocked;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowBlocked), findsOneWidget);
|
||||
expect(openSettingsButton(), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
await tester.tap(openSettingsButton());
|
||||
await settle(tester);
|
||||
expect(h.notifications.settingsOpened, 1);
|
||||
expect(snack(PushTexts.openSettingsFailed), findsNothing);
|
||||
|
||||
h.messaging.current = PushPermission.granted;
|
||||
await visitPhoneSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.current = PushPermission.blocked;
|
||||
h.notifications.opens = false;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
await tester.tap(openSettingsButton());
|
||||
await settle(tester);
|
||||
|
||||
expect(snack(PushTexts.openSettingsFailed), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('새 메일 알림만 꺼짐 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 켜졌으면 켜진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: false,
|
||||
sdkInt: 34,
|
||||
);
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowChannelOff), findsOneWidget);
|
||||
expect(openSettingsButton(), findsOneWidget);
|
||||
expect(h.push.puts, isEmpty);
|
||||
|
||||
h.notifications.current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: true,
|
||||
sdkInt: 34,
|
||||
);
|
||||
await visitPhoneSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isTrue);
|
||||
expect(findCopy(PushTexts.rowDescription), findsOneWidget);
|
||||
expect(h.push.puts, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('서버가 보낼 수 없으면 — 스위치는 누를 수 없고 쓸 수 없다는 글, 등록하지 않는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.push.config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0'},
|
||||
'pushAvailable': false,
|
||||
};
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowUnavailable), findsOneWidget);
|
||||
expect(openSettingsButton(), findsNothing);
|
||||
// 앱 설정은 세션 되살리기(미리 갱신)를 기다리지 않고 먼저 온다 — 등록하지 않는다. 앞으로 와도 그대로
|
||||
expect(h.push.puts, isEmpty);
|
||||
await visitPhoneSettings(tester);
|
||||
expect(h.push.puts, isEmpty);
|
||||
expect(h.push.deletes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('받을 수 없는 휴대폰이면 — 스위치는 누를 수 없고 받을 수 없다는 글', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
h.messaging.available = false;
|
||||
await h.pump(tester);
|
||||
await h.openSettings(tester);
|
||||
|
||||
expect(switchOf(tester).value, isFalse);
|
||||
expect(switchOf(tester).onChanged, isNull);
|
||||
expect(findCopy(PushTexts.rowUnsupported), findsOneWidget);
|
||||
expect(h.messaging.registerCalls, 0);
|
||||
expect(h.push.puts, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -109,6 +109,7 @@ class RecordingPushRegistrar implements PushRegistrar {
|
||||
final TokenStore _store;
|
||||
int registered = 0;
|
||||
int unregistered = 0;
|
||||
int paused = 0;
|
||||
bool? hadSessionAtUnregister;
|
||||
|
||||
/// 두면 지우기가 이것이 끝날 때까지 기다린다
|
||||
@@ -123,6 +124,9 @@ class RecordingPushRegistrar implements PushRegistrar {
|
||||
hadSessionAtUnregister = _store.hasSession;
|
||||
await unregisterGate?.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void pause() => paused++;
|
||||
}
|
||||
|
||||
/// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다
|
||||
|
||||
@@ -140,6 +140,7 @@ class LoginHarness {
|
||||
WidgetTester tester, {
|
||||
String? refreshToken,
|
||||
List<ProviderObserver> observers = const <ProviderObserver>[],
|
||||
List<Override> overrides = const <Override>[],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390 * 3, 1500 * 3);
|
||||
tester.view.devicePixelRatio = 3;
|
||||
@@ -173,6 +174,7 @@ class LoginHarness {
|
||||
overrides: <Override>[
|
||||
...base.overrides,
|
||||
authenticatorLauncherProvider.overrideWithValue(launcher),
|
||||
...overrides,
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
|
||||
@@ -140,6 +140,9 @@ class FakeMailBackend {
|
||||
FakeResponse? deletionFailure;
|
||||
FakeResponse? stateFailure;
|
||||
|
||||
/// 있으면 다음 새 메일 확인 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
|
||||
Completer<void>? stateGate;
|
||||
|
||||
/// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce])
|
||||
Completer<void>? listGate;
|
||||
bool listGateOnce = true;
|
||||
@@ -364,7 +367,10 @@ class FakeMailBackend {
|
||||
'skippedIds': skipped,
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.mailState, (_) {
|
||||
server.on('GET', MailApiPaths.mailState, (_) async {
|
||||
final Completer<void>? gate = stateGate;
|
||||
stateGate = null;
|
||||
await gate?.future;
|
||||
final FakeResponse? failure = stateFailure;
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{'state': state});
|
||||
|
||||
@@ -25,6 +25,9 @@ class ProviderHarness {
|
||||
late final RecordingNotifier notifier = RecordingNotifier(clock);
|
||||
late final RecordingPushRegistrar push = RecordingPushRegistrar(store);
|
||||
|
||||
/// 거짓이면 기기 등록을 기록하는 가짜로 바꾸지 않는다 — 세션이 진짜 새 메일 알림 컨트롤러를 부른다(알림 화면 시험)
|
||||
bool recordPush = true;
|
||||
|
||||
/// ProviderScope · ProviderContainer 에 넣을 덮어쓰기
|
||||
List<Override> get overrides => <Override>[
|
||||
tokenStoreProvider.overrideWithValue(store),
|
||||
@@ -32,7 +35,7 @@ class ProviderHarness {
|
||||
clockProvider.overrideWithValue(clock.call),
|
||||
errorNotifierProvider.overrideWithValue(notifier),
|
||||
userAgentProvider.overrideWithValue(kTestUserAgent),
|
||||
pushRegistrarProvider.overrideWithValue(push),
|
||||
if (recordPush) pushRegistrarProvider.overrideWithValue(push),
|
||||
];
|
||||
|
||||
/// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.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/push/notification_system.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_target.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/provider_retry.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
|
||||
/// 새 메일 알림 (서버가 싣는 데이터 `{ module: 'mail', kind: 'new-mail' }`)
|
||||
const PushMessage kNewMailPush = PushMessage(
|
||||
target: PushTarget(module: AppModuleId.mail),
|
||||
kind: PushKinds.newMail,
|
||||
);
|
||||
|
||||
/// 로그인이 저절로 끝났다는 알림
|
||||
const PushMessage kSessionEndedPush = PushMessage(
|
||||
target: PushTarget(module: AppModuleId.mail),
|
||||
kind: PushKinds.sessionEnded,
|
||||
);
|
||||
|
||||
/// 가짜 FCM — 권한 · 등록 · 소식을 시험이 손으로 보낸다. 부른 차례를 [calls] 에 적는다
|
||||
class FakePushMessaging implements PushMessaging {
|
||||
FakePushMessaging({this.available = true});
|
||||
|
||||
bool available;
|
||||
|
||||
/// 지금 권한
|
||||
PushPermission current = PushPermission.notAsked;
|
||||
|
||||
/// [requestPermission] 이 고를 값 — null 이면 창이 뜨지 않는다(지금 권한 그대로)
|
||||
PushPermission? answer = PushPermission.granted;
|
||||
int permissionRequests = 0;
|
||||
|
||||
/// [register] 가 끝나면 보낼 FID — null 이면 소식을 보내지 않는다(시험이 [emitFid] 로)
|
||||
String? fidOnRegister = 'fid-1';
|
||||
Object? registerError;
|
||||
Object? unregisterError;
|
||||
Completer<void>? registerGate;
|
||||
Completer<void>? unregisterGate;
|
||||
|
||||
/// 꺼져 있던 앱을 띄운 알림
|
||||
PushMessage? initial;
|
||||
|
||||
/// 부른 차례 — 'register' · 'unregister' · 'requestPermission'
|
||||
final List<String> calls = <String>[];
|
||||
|
||||
final StreamController<String> _registered =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<PushMessage> _foreground =
|
||||
StreamController<PushMessage>.broadcast();
|
||||
final StreamController<PushMessage> _opened =
|
||||
StreamController<PushMessage>.broadcast();
|
||||
|
||||
int get registerCalls =>
|
||||
calls.where((String call) => call == 'register').length;
|
||||
|
||||
int get unregisterCalls =>
|
||||
calls.where((String call) => call == 'unregister').length;
|
||||
|
||||
/// FCM 이 FID 를 알린다(등록 · FID 바뀜)
|
||||
void emitFid(String fid) => _registered.add(fid);
|
||||
|
||||
/// 앱이 앞에 있을 때 알림이 왔다
|
||||
void receive(PushMessage message) => _foreground.add(message);
|
||||
|
||||
/// 앱이 뒤에 있을 때 알림을 눌렀다
|
||||
void tap(PushMessage message) => _opened.add(message);
|
||||
|
||||
@override
|
||||
bool get isAvailable => available;
|
||||
|
||||
@override
|
||||
Future<PushPermission> permission() async => current;
|
||||
|
||||
@override
|
||||
Future<PushPermission> requestPermission() async {
|
||||
permissionRequests++;
|
||||
calls.add('requestPermission');
|
||||
final PushPermission? chosen = answer;
|
||||
if (chosen != null) current = chosen;
|
||||
return current;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> register() async {
|
||||
calls.add('register');
|
||||
await registerGate?.future;
|
||||
final Object? error = registerError;
|
||||
if (error != null) throw error;
|
||||
final String? fid = fidOnRegister;
|
||||
if (fid != null) scheduleMicrotask(() => _registered.add(fid));
|
||||
}
|
||||
|
||||
/// [unregister] 를 부를 때 시험이 볼 것(그때 토큰이 남아 있었나 등)
|
||||
void Function()? onUnregister;
|
||||
|
||||
@override
|
||||
Future<void> unregister() async {
|
||||
calls.add('unregister');
|
||||
onUnregister?.call();
|
||||
await unregisterGate?.future;
|
||||
final Object? error = unregisterError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@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 PushMessage? message = initial;
|
||||
initial = null;
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// 가짜 알림창 · 알림 설정
|
||||
class FakeNotificationSystem implements NotificationSystem {
|
||||
NotificationStatus current = const NotificationStatus(
|
||||
appEnabled: true,
|
||||
channelEnabled: true,
|
||||
sdkInt: 34,
|
||||
);
|
||||
int statusCalls = 0;
|
||||
int clears = 0;
|
||||
int settingsOpened = 0;
|
||||
|
||||
/// [openSettings] 가 열었다고 답하는가
|
||||
bool opens = true;
|
||||
|
||||
@override
|
||||
Future<NotificationStatus> status() async {
|
||||
statusCalls++;
|
||||
return current;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openSettings() async {
|
||||
settingsOpened++;
|
||||
return opens;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearNewMail() async => clears++;
|
||||
}
|
||||
|
||||
/// 메모리 새 메일 알림 값 — 쓴 값을 [values] 로 본다
|
||||
class MemoryPushPreferences implements PushPreferences {
|
||||
MemoryPushPreferences([this.values = PushPreferenceValues.initial]);
|
||||
|
||||
PushPreferenceValues values;
|
||||
|
||||
/// 두면 읽기가 이것이 끝날 때까지 기다린다
|
||||
Completer<void>? loadGate;
|
||||
|
||||
@override
|
||||
Future<PushPreferenceValues> load() async {
|
||||
await loadGate?.future;
|
||||
return values;
|
||||
}
|
||||
|
||||
PushPreferenceValues _with({
|
||||
bool? enabled,
|
||||
bool? offerDismissed,
|
||||
bool? pendingUnregister,
|
||||
Object? lastFid = _keep,
|
||||
bool? serverRegistered,
|
||||
}) => PushPreferenceValues(
|
||||
enabled: enabled ?? values.enabled,
|
||||
offerDismissed: offerDismissed ?? values.offerDismissed,
|
||||
pendingUnregister: pendingUnregister ?? values.pendingUnregister,
|
||||
lastFid: identical(lastFid, _keep) ? values.lastFid : lastFid as String?,
|
||||
serverRegistered: serverRegistered ?? values.serverRegistered,
|
||||
);
|
||||
|
||||
static const Object _keep = Object();
|
||||
|
||||
@override
|
||||
Future<void> setEnabled(bool value) async => values = _with(enabled: value);
|
||||
|
||||
@override
|
||||
Future<void> setOfferDismissed(bool value) async =>
|
||||
values = _with(offerDismissed: value);
|
||||
|
||||
@override
|
||||
Future<void> setPendingUnregister(bool value) async =>
|
||||
values = _with(pendingUnregister: value);
|
||||
|
||||
@override
|
||||
Future<void> setLastFid(String? fid) async => values = _with(lastFid: fid);
|
||||
|
||||
@override
|
||||
Future<void> setServerRegistered(bool value) async =>
|
||||
values = _with(serverRegistered: value);
|
||||
}
|
||||
|
||||
/// 앞에 보이는지를 시험이 바꾼다 (앱 생명주기 대신)
|
||||
class FakeForeground extends AppForeground {
|
||||
@override
|
||||
bool build() => true;
|
||||
|
||||
void set(bool value) => state = value;
|
||||
}
|
||||
|
||||
/// 가짜 서버의 새 메일 알림 · 앱 설정 경로
|
||||
class FakePushBackend {
|
||||
FakePushBackend(this.server);
|
||||
|
||||
final FakeServer server;
|
||||
|
||||
/// 받은 등록(PUT 본문 · Bearer)
|
||||
final List<({Map<String, Object?> body, String? bearer})> puts =
|
||||
<({Map<String, Object?> body, String? bearer})>[];
|
||||
final List<RequestOptions> deletes = <RequestOptions>[];
|
||||
final List<RequestOptions> seen = <RequestOptions>[];
|
||||
final List<RequestOptions> configRequests = <RequestOptions>[];
|
||||
|
||||
/// 두면 그 응답으로 답한다(실패 시험)
|
||||
FakeResponse? putFailure;
|
||||
FakeResponse? deleteFailure;
|
||||
Completer<void>? putGate;
|
||||
Completer<void>? deleteGate;
|
||||
|
||||
/// 앱 설정의 `data` — [configResponse] 가 있으면 그것으로 답한다
|
||||
Map<String, Object?> config = <String, Object?>{
|
||||
'minSupportedVersion': <String, Object?>{'android': '1.0.0', 'ios': null},
|
||||
'pushAvailable': true,
|
||||
};
|
||||
FakeResponse? configResponse;
|
||||
|
||||
void install() {
|
||||
server
|
||||
..on('PUT', ApiPaths.pushRegistration, (RequestOptions options) async {
|
||||
final Object? data = options.data;
|
||||
puts.add((
|
||||
body: data is Map<String, Object?>
|
||||
? Map<String, Object?>.of(data)
|
||||
: <String, Object?>{},
|
||||
bearer: bearerTokenOf(options),
|
||||
));
|
||||
await putGate?.future;
|
||||
return putFailure ??
|
||||
FakeResponse.ok(<String, Object?>{
|
||||
'updatedAt': '2026-11-07T05:00:00.000Z',
|
||||
});
|
||||
})
|
||||
..on('DELETE', ApiPaths.pushRegistration, (RequestOptions options) async {
|
||||
deletes.add(options);
|
||||
await deleteGate?.future;
|
||||
return deleteFailure ?? const FakeResponse(204, null, rawBody: '');
|
||||
})
|
||||
..on('DELETE', ApiPaths.pushRegistrationUnseen, (RequestOptions options) {
|
||||
seen.add(options);
|
||||
return const FakeResponse(204, null, rawBody: '');
|
||||
})
|
||||
..on('GET', ApiPaths.appConfig, (RequestOptions options) {
|
||||
configRequests.add(options);
|
||||
return configResponse ?? FakeResponse.ok(config);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 새 메일 알림 시험대 — 진짜 세션 노티파이어 · 알림 컨트롤러를 가짜 FCM · 알림창 · 서버로 띄운다
|
||||
/// ([ProviderHarness] 와 달리 기기 등록을 가짜로 바꾸지 않는다)
|
||||
class PushHarness {
|
||||
PushHarness();
|
||||
|
||||
final FakeServer server = FakeServer();
|
||||
final MemorySecureStore secure = MemorySecureStore();
|
||||
final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker();
|
||||
final ManualClock clock = ManualClock();
|
||||
final FakePushMessaging messaging = FakePushMessaging();
|
||||
final FakeNotificationSystem notifications = FakeNotificationSystem();
|
||||
final MemoryPushPreferences prefs = MemoryPushPreferences();
|
||||
late final FakePushBackend push = FakePushBackend(server);
|
||||
|
||||
late final TokenStore store = TokenStore(
|
||||
secure: secure,
|
||||
firstRun: firstRun,
|
||||
clock: clock.call,
|
||||
);
|
||||
late final RecordingNotifier notifier = RecordingNotifier(clock);
|
||||
|
||||
/// 시험마다 더하는 덮어쓰기
|
||||
final List<Override> extra = <Override>[];
|
||||
|
||||
List<Override> get overrides => <Override>[
|
||||
tokenStoreProvider.overrideWithValue(store),
|
||||
httpClientAdapterProvider.overrideWithValue(server),
|
||||
clockProvider.overrideWithValue(clock.call),
|
||||
errorNotifierProvider.overrideWithValue(notifier),
|
||||
userAgentProvider.overrideWithValue(kTestUserAgent),
|
||||
appVersionProvider.overrideWithValue('1.0.0'),
|
||||
pushMessagingProvider.overrideWithValue(messaging),
|
||||
notificationSystemProvider.overrideWithValue(notifications),
|
||||
pushPreferencesProvider.overrideWithValue(prefs),
|
||||
appForegroundProvider.overrideWith(FakeForeground.new),
|
||||
...extra,
|
||||
];
|
||||
|
||||
ProviderContainer container() =>
|
||||
ProviderContainer(overrides: overrides, retry: noAutomaticRetry);
|
||||
|
||||
/// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다 — 갱신 · me 는 [kTestEmail] 의 세션으로 답한다
|
||||
Future<void> prepare({String? refreshToken}) async {
|
||||
if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken;
|
||||
push.install();
|
||||
server
|
||||
..on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: clock.now),
|
||||
}),
|
||||
)
|
||||
..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()))
|
||||
..on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok());
|
||||
await store.prepare();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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_controller.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_state.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
import 'mail_fakes.dart';
|
||||
import 'mail_harness.dart';
|
||||
import 'push_fakes.dart';
|
||||
|
||||
/// 새 메일 알림 화면 시험대 — 로그인한 앱 전체(메일 시험대)를 띄우고, 세션이 진짜 새 메일 알림 컨트롤러를 부르게 한다.
|
||||
/// 기기 쪽(FCM · 알림창 · 이 휴대폰의 값)과 서버(`/push-registrations/current` · `/app-config`)는 가짜다.
|
||||
///
|
||||
/// 처음 모습: 안드로이드 14 · 알림 권한 허용 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 — 시험마다 [pump] 전에 바꾼다.
|
||||
class PushScreenHarness {
|
||||
PushScreenHarness() {
|
||||
app.base.recordPush = false;
|
||||
messaging.current = PushPermission.granted;
|
||||
app.overrides.addAll(<Override>[
|
||||
appVersionProvider.overrideWithValue(kPushScreenAppVersion),
|
||||
pushMessagingProvider.overrideWithValue(messaging),
|
||||
notificationSystemProvider.overrideWithValue(notifications),
|
||||
pushPreferencesProvider.overrideWithValue(prefs),
|
||||
]);
|
||||
}
|
||||
|
||||
final MailAppHarness app = MailAppHarness();
|
||||
final FakePushMessaging messaging = FakePushMessaging();
|
||||
final FakeNotificationSystem notifications = FakeNotificationSystem();
|
||||
final MemoryPushPreferences prefs = MemoryPushPreferences();
|
||||
late final FakePushBackend push = FakePushBackend(app.server);
|
||||
|
||||
FakeServer get server => app.server;
|
||||
RecordingNotifier get notifier => app.notifier;
|
||||
|
||||
/// 지금 알림 상태
|
||||
PushState get state => app.container.read(pushControllerProvider);
|
||||
|
||||
/// 앱을 띄운다 — 세션을 되살리고 알림 등록(FCM · 서버)까지 마친 뒤 [location](없으면 받은편지함)을 그린다
|
||||
Future<void> pump(WidgetTester tester, {String? location}) async {
|
||||
push.install();
|
||||
app.mail.seed(kInboxId, 2);
|
||||
await app.pump(tester, location: location);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// 설정 탭을 연다
|
||||
Future<void> openSettings(WidgetTester tester) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(NavigationBar),
|
||||
matching: find.text(SettingsTexts.home),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
}
|
||||
}
|
||||
|
||||
/// 시험 앱의 판
|
||||
const String kPushScreenAppVersion = '1.2.0';
|
||||
|
||||
/// 그리기 · 가짜 서버 응답이 모두 끝날 때까지 — 서버 응답은 시계가 가야 온다(화면이 가만히 있어도)
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_core
|
||||
flutter_secure_storage_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user