From e03eda1fd347df886ff22bdb2d1c7a618c9b0c89 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 00:46:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=83=88=20=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC(FCM)=20=C2=B7=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=ED=8C=90=20=ED=99=95=EC=9D=B8=20=C2=B7=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다. - firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다. google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다). - 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다. - 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다 (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다). - 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다. - 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다). - 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다. - 시험 932 → 1,056. Co-Authored-By: Claude Opus 5.5 --- flutter/android/.gitignore | 4 + flutter/android/app/build.gradle.kts | 19 + .../android/app/src/main/AndroidManifest.xml | 28 +- .../kr/it/arasystem/app/MainActivity.kt | 7 + .../it/arasystem/app/NewMailNotifications.kt | 126 +++ .../app/src/main/res/values/colors.xml | 2 + .../app/src/main/res/values/strings.xml | 11 + flutter/android/settings.gradle.kts | 3 + flutter/lib/core/api/api_paths.dart | 15 +- flutter/lib/core/api/dio_client.dart | 10 +- flutter/lib/core/api/dio_provider.dart | 6 + flutter/lib/core/api/user_agent.dart | 64 +- flutter/lib/core/app_config/app_config.dart | 116 +++ .../app_config/app_config_controller.dart | 138 ++++ .../app_config/app_config_repository.dart | 32 + .../lib/core/constants/app_info_provider.dart | 6 + flutter/lib/core/constants/screen_titles.dart | 3 + .../core/push/firebase_push_messaging.dart | 123 +++ .../lib/core/push/notification_system.dart | 127 +++ flutter/lib/core/push/push_controller.dart | 678 ++++++++++++++++ flutter/lib/core/push/push_events.dart | 60 ++ flutter/lib/core/push/push_messaging.dart | 142 ++++ flutter/lib/core/push/push_preferences.dart | 144 ++++ flutter/lib/core/push/push_registration.dart | 92 ++- flutter/lib/core/push/push_state.dart | 250 ++++++ flutter/lib/core/push/push_target.dart | 5 +- flutter/lib/core/push/push_texts.dart | 47 ++ flutter/lib/core/router/app_router.dart | 41 +- flutter/lib/core/router/app_routes.dart | 3 + flutter/lib/core/router/push_navigation.dart | 46 ++ flutter/lib/core/router/session_redirect.dart | 5 + flutter/lib/core/theme/app_colors.dart | 7 + flutter/lib/core/theme/app_spacing.dart | 20 + flutter/lib/core/theme/app_theme.dart | 32 + .../app_update/data/app_store_launcher.dart | 52 ++ .../presentation/app_update_texts.dart | 22 + .../presentation/pages/app_update_page.dart | 121 +++ .../providers/session_provider.dart | 18 +- .../presentation/widgets/login_frame.dart | 63 +- .../presentation/pages/mail_list_page.dart | 45 +- .../pages/message_compose_page.dart | 21 +- .../providers/compose_controller.dart | 83 +- .../providers/mail_state_watcher.dart | 42 +- .../presentation/widgets/push_offer_line.dart | 152 ++++ .../presentation/pages/settings_page.dart | 441 +++++++++++ .../settings/presentation/settings_texts.dart | 30 +- .../widgets/settings_notification_row.dart | 271 +++++++ .../features/settings/settings_routes.dart | 110 +-- flutter/lib/main.dart | 19 +- .../Flutter/GeneratedPluginRegistrant.swift | 4 + flutter/pubspec.lock | 56 ++ flutter/pubspec.yaml | 6 + flutter/test/app_test.dart | 34 +- .../test/core/app_config/app_config_test.dart | 372 +++++++++ flutter/test/core/push/push_app_test.dart | 349 ++++++++ .../test/core/push/push_controller_test.dart | 744 ++++++++++++++++++ flutter/test/core/push/push_mapping_test.dart | 181 +++++ .../push_registration_repository_test.dart | 110 +++ flutter/test/core/push/push_state_test.dart | 149 ++++ .../core/router/session_redirect_test.dart | 68 +- .../app_update/app_update_page_test.dart | 139 ++++ .../test/features/auth/login_pages_test.dart | 16 + .../features/auth/session_provider_test.dart | 72 ++ .../features/mail/mail_list_page_test.dart | 15 +- .../features/mail/push_offer_line_test.dart | 184 +++++ .../features/settings/settings_page_test.dart | 460 +++++++++++ flutter/test/support/fakes.dart | 4 + flutter/test/support/login_fakes.dart | 2 + flutter/test/support/mail_fakes.dart | 8 +- flutter/test/support/provider_harness.dart | 5 +- flutter/test/support/push_fakes.dart | 339 ++++++++ flutter/test/support/push_screen_harness.dart | 73 ++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 74 files changed, 7067 insertions(+), 229 deletions(-) create mode 100644 flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt create mode 100644 flutter/android/app/src/main/res/values/strings.xml create mode 100644 flutter/lib/core/app_config/app_config.dart create mode 100644 flutter/lib/core/app_config/app_config_controller.dart create mode 100644 flutter/lib/core/app_config/app_config_repository.dart create mode 100644 flutter/lib/core/push/firebase_push_messaging.dart create mode 100644 flutter/lib/core/push/notification_system.dart create mode 100644 flutter/lib/core/push/push_controller.dart create mode 100644 flutter/lib/core/push/push_events.dart create mode 100644 flutter/lib/core/push/push_messaging.dart create mode 100644 flutter/lib/core/push/push_preferences.dart create mode 100644 flutter/lib/core/push/push_state.dart create mode 100644 flutter/lib/core/push/push_texts.dart create mode 100644 flutter/lib/core/router/push_navigation.dart create mode 100644 flutter/lib/features/app_update/data/app_store_launcher.dart create mode 100644 flutter/lib/features/app_update/presentation/app_update_texts.dart create mode 100644 flutter/lib/features/app_update/presentation/pages/app_update_page.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/push_offer_line.dart create mode 100644 flutter/lib/features/settings/presentation/pages/settings_page.dart create mode 100644 flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart create mode 100644 flutter/test/core/app_config/app_config_test.dart create mode 100644 flutter/test/core/push/push_app_test.dart create mode 100644 flutter/test/core/push/push_controller_test.dart create mode 100644 flutter/test/core/push/push_mapping_test.dart create mode 100644 flutter/test/core/push/push_registration_repository_test.dart create mode 100644 flutter/test/core/push/push_state_test.dart create mode 100644 flutter/test/features/app_update/app_update_page_test.dart create mode 100644 flutter/test/features/mail/push_offer_line_test.dart create mode 100644 flutter/test/features/settings/settings_page_test.dart create mode 100644 flutter/test/support/push_fakes.dart create mode 100644 flutter/test/support/push_screen_harness.dart diff --git a/flutter/android/.gitignore b/flutter/android/.gitignore index be3943c..69110b0 100644 --- a/flutter/android/.gitignore +++ b/flutter/android/.gitignore @@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다). +# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림") +app/google-services.json diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 73e74a2..14eed18 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -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 \"새 메일 알림\").", + ) + } + } +} diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index ffb7c35..9c1ef3c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,14 @@ - + + + + + + + + + + when (call.method) { diff --git a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt new file mode 100644 index 0000000..f0e191f --- /dev/null +++ b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt @@ -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 = 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 + } +} diff --git a/flutter/android/app/src/main/res/values/colors.xml b/flutter/android/app/src/main/res/values/colors.xml index a8f0bf8..7df9b15 100644 --- a/flutter/android/app/src/main/res/values/colors.xml +++ b/flutter/android/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ + + #0E7C86 #0B3640 diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e0cc52d --- /dev/null +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + + new_mail + + 새 메일 + 새 메일이 오면 알려 드립니다. + diff --git a/flutter/android/settings.gradle.kts b/flutter/android/settings.gradle.kts index ca7fe06..4738537 100644 --- a/flutter/android/settings.gradle.kts +++ b/flutter/android/settings.gradle.kts @@ -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") diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index f0409f9..f4ca6c2 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -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 noRefresh = { appLogin, appRefresh, appLogout, + appConfig, }; } diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 39b1512..e60fa69 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -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; } diff --git a/flutter/lib/core/api/dio_provider.dart b/flutter/lib/core/api/dio_provider.dart index 18e68a0..64cdcc3 100644 --- a/flutter/lib/core/api/dio_provider.dart +++ b/flutter/lib/core/api/dio_provider.dart @@ -68,6 +68,12 @@ final Provider dioProvider = Provider( (Ref ref) => ref.watch(dioClientProvider).dio, ); +/// 로그인 없는 공개 요청의 Dio — 인증 인터셉터 줄 밖(Bearer · 갱신 · 알림 없음, 언래핑 · 코드 정규화만). +/// 앱을 띄우자마자 세션 되살리기와 나란히 묻는 앱 설정(`GET /api/app-config`)이 쓴다 +final Provider publicDioProvider = Provider( + (Ref ref) => ref.watch(dioClientProvider).publicDio, +); + /// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다 final Provider tokenRefresherProvider = Provider( diff --git a/flutter/lib/core/api/user_agent.dart b/flutter/lib/core/api/user_agent.dart index daf7481..88f3579 100644 --- a/flutter/lib/core/api/user_agent.dart +++ b/flutter/lib/core/api/user_agent.dart @@ -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 _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, ); } diff --git a/flutter/lib/core/app_config/app_config.dart b/flutter/lib/core/app_config/app_config.dart new file mode 100644 index 0000000..cc62d60 --- /dev/null +++ b/flutter/lib/core/app_config/app_config.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; + +/// 앱 판 — 숫자 셋(major.minor.patch). 판 이름(`PackageInfo.version` — pubspec 의 build-name, User-Agent 와 같은 값)을 견준다 +@immutable +class AppVersion implements Comparable { + 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 parts = value.split('.'); + if (parts.isEmpty || parts.length > 3) return null; + final List numbers = []; + 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) return const AppConfig(); + final Object? versions = data['minSupportedVersion']; + final Object? minimum = versions is Map + ? 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; +} diff --git a/flutter/lib/core/app_config/app_config_controller.dart b/flutter/lib/core/app_config/app_config_controller.dart new file mode 100644 index 0000000..3c814d3 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_controller.dart @@ -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 appConfigRepositoryProvider = + Provider( + (Ref ref) => AppConfigRepository(ref.watch(publicDioProvider)), + ); + +/// 앱 설정 — 앱에 하나. 라우터(최소 판)와 새 메일 알림(`pushAvailable`)이 본다. +final NotifierProvider appConfigProvider = + NotifierProvider( + AppConfigController.new, + retry: noAutomaticRetry, + ); + +/// 앱 설정을 묻는다 — 앱을 띄울 때 한 번(세션 되살리기와 나란히 — 기다리지 않는다), 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면. +/// +/// 실패(끊긴 망 · 5xx)는 알리지 않고 막지 않는다 — 앞으로 올 때 1분 간격으로 다시. 404(그 경로가 없는 옛 서버)는 받은 것으로 본다 +/// (최소 판 없음 · 알림 쓸 수 없음). 모양이 틀리면 막지 않는다. +class AppConfigController extends Notifier { + Future? _loading; + + @override + AppConfigState build() { + ref.listen(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 refresh() { + final Future? running = _loading; + if (running != null) return running; + final Future 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 _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 appUpdateRequiredProvider = Provider( + (Ref ref) => isAppUpdateRequired( + appVersion: ref.watch(appVersionProvider), + minimum: ref.watch(appConfigProvider).config?.minSupportedVersion, + ), +); + +/// 서버가 새 메일 알림을 보낼 수 있는가 — 모르면 null(쓸 수 있는 것으로 본다) +final Provider pushAvailableProvider = Provider( + (Ref ref) => ref.watch(appConfigProvider).config?.pushAvailable, +); diff --git a/flutter/lib/core/app_config/app_config_repository.dart b/flutter/lib/core/app_config/app_config_repository.dart new file mode 100644 index 0000000..fe48816 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_repository.dart @@ -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 fetch() async { + try { + final Response response = await _dio.get( + ApiPaths.appConfig, + options: ApiOptions.silent(), + ); + return AppConfig.fromJson(response.data); + } on DioException catch (error) { + if (apiErrorCodeOf(error) == _notFoundCode) return AppConfig.notProvided; + rethrow; + } + } +} diff --git a/flutter/lib/core/constants/app_info_provider.dart b/flutter/lib/core/constants/app_info_provider.dart index 5900f14..31f9ec4 100644 --- a/flutter/lib/core/constants/app_info_provider.dart +++ b/flutter/lib/core/constants/app_info_provider.dart @@ -5,3 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final Provider appVersionProvider = Provider( (Ref ref) => null, ); + +/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다. +/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다) +final Provider appPackageNameProvider = Provider( + (Ref ref) => null, +); diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart index 5bdc98a..b25abc4 100644 --- a/flutter/lib/core/constants/screen_titles.dart +++ b/flutter/lib/core/constants/screen_titles.dart @@ -17,4 +17,7 @@ abstract final class ScreenTitles { /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) static const String twoFactorSetup = '2단계 인증을 켜 주세요'; + + /// 앱을 업데이트해야 한다 — 업데이트 화면의 제목(보드 `App1Push` ⑩) + static const String update = '앱을 업데이트해 주세요'; } diff --git a/flutter/lib/core/push/firebase_push_messaging.dart b/flutter/lib/core/push/firebase_push_messaging.dart new file mode 100644 index 0000000..2a6e685 --- /dev/null +++ b/flutter/lib/core/push/firebase_push_messaging.dart @@ -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.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(>[ + _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 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> _subscriptions = + >[]; + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.broadcast(); + + @override + bool get isAvailable => true; + + @override + Future permission() async { + final NotificationSettings settings = await _messaging + .getNotificationSettings(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future requestPermission() async { + final NotificationSettings settings = await _messaging.requestPermission(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future register() => _messaging.register(); + + @override + Future unregister() => _messaging.unregister(); + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future initialOpen() async { + final RemoteMessage? message = await _messaging.getInitialMessage(); + return message == null ? null : pushMessageOf(message); + } +} diff --git a/flutter/lib/core/push/notification_system.dart b/flutter/lib/core/push/notification_system.dart new file mode 100644 index 0000000..5a643f9 --- /dev/null +++ b/flutter/lib/core/push/notification_system.dart @@ -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 status(); + + /// 휴대폰의 알림 설정 화면을 연다 — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참 + Future openSettings(); + + /// 알림창의 "새 메일 N통"을 지운다 — 다른 알림은 건드리지 않는다 + Future 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 status() async { + try { + final Map? reply = await channel + .invokeMapMethod(statusMethod); + return notificationStatusOf(reply); + } on PlatformException { + return NotificationStatus.unknown; + } on MissingPluginException { + return NotificationStatus.unknown; + } + } + + @override + Future openSettings() async { + try { + return await channel.invokeMethod(openSettingsMethod) == + openedResult; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } + } + + @override + Future clearNewMail() async { + try { + await channel.invokeMethod(clearNewMailMethod); + } on PlatformException { + // 지우지 못했다 — 다음에 앞으로 올 때 다시 지운다 + } on MissingPluginException { + // 채널이 없는 플랫폼 + } + } +} + +/// 채널 답 → [NotificationStatus] — 모양이 틀린 칸은 막지 않는 쪽(켜짐 · 판 모름)으로 본다 +NotificationStatus notificationStatusOf(Map? 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 notificationSystemProvider = + Provider( + (Ref ref) => const PlatformNotificationSystem(), + ); diff --git a/flutter/lib/core/push/push_controller.dart b/flutter/lib/core/push/push_controller.dart new file mode 100644 index 0000000..2571362 --- /dev/null +++ b/flutter/lib/core/push/push_controller.dart @@ -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 pushControllerProvider = + NotifierProvider(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 implements PushRegistrar { + late PushMessaging _messaging; + late NotificationSystem _notifications; + late PushPreferences _prefs; + + final List> _subscriptions = + >[]; + + /// 이 휴대폰의 값 · 권한을 다 읽었다 — 맞추기 · 켜기 · 끄기가 먼저 기다린다 + Future _ready = Future.value(); + + /// 서버 일 · 기기 읽기를 차례로 + Future _queue = Future.value(); + bool _reconcileQueued = false; + + /// FCM 등록 · 풀기를 차례로(플러그인은 둘을 다른 스레드에서 돌린다) + Future _fcmQueue = Future.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? _fcmRegistering; + bool _pendingUnregister = false; + + /// FCM 등록 세대 — 풀기를 시작할 때마다 오른다. 등록은 시작한 세대가 그대로일 때만 마친 것으로 친다 + int _fcmGeneration = 0; + + /// 줄에 섰거나 도는 풀기의 수 — 그동안 온 등록 소식은 풀리는 등록의 것이라 쓰지 않는다 + int _fcmUnregistering = 0; + final List> _fidWaiters = >[]; + + // ── 봤음 ── + 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(>[ + _messaging.onRegistered.listen(_onRegistered), + _messaging.onForegroundMessage.listen(_onForegroundMessage), + _messaging.onOpened.listen(_onOpened), + ]); + ref + ..listen(appForegroundProvider, (bool? previous, bool next) { + if (next && previous == false) _cameToForeground(); + if (!next && previous == true) _wentToBackground(); + }) + ..listen(pushAvailableProvider, (bool? previous, bool? next) { + state = state.copyWith(server: _serverOf(next)); + _requestReconcile(); + }); + // 꺼져 있던 앱을 띄운 알림 — 라우터가 가져간다 + unawaited( + _messaging.initialOpen().then((PushMessage? opened) { + if (opened != null && ref.mounted) { + ref.read(pushEventsProvider.notifier).add(opened, opened: true); + } + }, onError: (Object _, StackTrace _) {}), + ); + // build 안에서는 상태를 바꾸지 않는다 — 다음 차례에 읽기 시작한다 + _ready = Future.microtask(_initialize); + return PushState( + deviceSupported: true, + server: _serverOf(ref.read(pushAvailableProvider)), + ); + } + + // ── 화면이 부르는 일 ─────────────────────────────── + + /// [알림 켜기](권하기 줄) · 설정 스위치를 켰다 — 권한이 없으면 먼저 시스템 창(직원이 누른 뒤에만). 허락되면 FCM 등록 → 서버 등록(`PUT`)이 + /// 끝나야 켬을 둔다. 서버 등록이 실패하면 이 휴대폰의 값을 끔으로 두고(스위치가 꺼진 채로 돌아간다 — 처음 값 켬에서 켰어도, 저절로 다시 하지 않는다) + /// 그 코드의 사전 문구를 알린다(인터셉터 · 등록 소식이 없으면 `SYS_001`) + Future 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 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 openSettings() => _notifications.openSettings(); + + // ── 세션 노티파이어가 부르는 일 (PushRegistrar) ────── + + @override + Future register() async { + // 세션 노티파이어가 상태를 바꾸는 중에 부를 수 있다 — 다음 차례에 바꾼다 + await Future.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 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 _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 _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 _readPermission() async { + try { + return await _messaging.permission(); + } on Object { + return state.permission ?? PushPermission.denied; + } + } + + Future _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 _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 _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 _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 registering = _fcm(() async { + await _messaging.register(); + // 그사이 풀기를 바랐으면(로그아웃) 이 등록은 마친 것으로 치지 않는다 — 뒤에 선 풀기가 푼다 + if (generation == _fcmGeneration) _fcmRegistered = true; + }); + _fcmRegistering = registering; + unawaited( + registering + .then( + (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 _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 _waitForFid() async { + final Completer waiter = Completer(); + _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 waiter in List>.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 _) { + 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 _) {}, + // 알리지 않는다 — 다음 앞으로 옴에 다시 + onError: (Object _, StackTrace _) {}, + ), + ); + } + + void _cancelSeen() { + _seenPending = false; + _seenTimer?.cancel(); + _seenTimer = null; + } + + // ── 차례 ────────────────────────────────────── + + Future _serial(Future Function() operation) { + final Future result = _queue.then((void _) => operation()); + _queue = result.then((T _) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + Future _fcm(Future Function() operation) { + final Future result = _fcmQueue.then((void _) => operation()); + _fcmQueue = result.then( + (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 subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + _seenTimer?.cancel(); + _seenTimer = null; + _completeFidWaiters(null); + } +} + +/// 직원이 켠 등록의 결과 — 실패면 이 휴대폰의 값을 끔으로, 건너뜀(그사이 세션이 바뀜)이면 켬 · 끔을 건드리지 않는다 +enum _UserRegistration { registered, failed, skipped } diff --git a/flutter/lib/core/push/push_events.dart b/flutter/lib/core/push/push_events.dart new file mode 100644 index 0000000..0683d9f --- /dev/null +++ b/flutter/lib/core/push/push_events.dart @@ -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 pushEventsProvider = + NotifierProvider(PushEvents.new); + +class PushEvents extends Notifier { + 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; + } +} diff --git a/flutter/lib/core/push/push_messaging.dart b/flutter/lib/core/push/push_messaging.dart new file mode 100644 index 0000000..be4da24 --- /dev/null +++ b/flutter/lib/core/push/push_messaging.dart @@ -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 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 permission(); + + /// 알림 권한을 묻는다 — 안드로이드 13 이상에서 묻지 않았거나 한 번 거절했으면 시스템 창을 띄운다. 직원이 [알림 켜기]를 누른 뒤에만 부른다 + Future requestPermission(); + + /// FCM 에 이 앱을 등록한다 — 끝나면 [onRegistered] 로 FID 가 온다(값을 돌려주지 않는다) + Future register(); + + /// FCM 등록을 푼다 — 그 FID 로 보낸 알림은 오지 않는다 + Future unregister(); + + /// 등록된 FID — 등록할 때마다 · 바뀔 때마다 온다. **듣기 전에 온 값은 사라진다** — [register] 전에 듣는다 + Stream get onRegistered; + + /// 앱이 앞에 있을 때 온 알림 — 알림창에 띄우지 않는다 + Stream get onForegroundMessage; + + /// 앱이 뒤에 있을 때 누른 알림 + Stream get onOpened; + + /// 꺼져 있던 앱을 띄운 알림 — 한 번만 온다(다시 부르면 null) + Future initialOpen(); +} + +/// 받을 수 없는 휴대폰 — 아무것도 하지 않는다(main 이 Firebase 를 띄우지 못했을 때 · 시험의 기본값) +class UnavailablePushMessaging implements PushMessaging { + const UnavailablePushMessaging(); + + @override + bool get isAvailable => false; + + @override + Future permission() async => PushPermission.blocked; + + @override + Future requestPermission() async => PushPermission.blocked; + + @override + Future register() async {} + + @override + Future unregister() async {} + + @override + Stream get onRegistered => const Stream.empty(); + + @override + Stream get onForegroundMessage => + const Stream.empty(); + + @override + Stream get onOpened => const Stream.empty(); + + @override + Future initialOpen() async => null; +} + +/// 알림 받기 — main 이 Firebase 를 띄웠으면 `FirebasePushMessaging` 으로 덮어쓴다. 시험은 가짜로 +final Provider pushMessagingProvider = Provider( + (Ref ref) => const UnavailablePushMessaging(), +); diff --git a/flutter/lib/core/push/push_preferences.dart b/flutter/lib/core/push/push_preferences.dart new file mode 100644 index 0000000..3819026 --- /dev/null +++ b/flutter/lib/core/push/push_preferences.dart @@ -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 load(); + + Future setEnabled(bool value); + + Future setOfferDismissed(bool value); + + Future setPendingUnregister(bool value); + + Future setLastFid(String? fid); + + Future 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 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 setEnabled(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(enabledKey, value), + ); + + @override + Future setOfferDismissed(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(offerDismissedKey, value), + ); + + @override + Future setPendingUnregister(bool value) => _write( + (SharedPreferencesAsync prefs) => + prefs.setBool(pendingUnregisterKey, value), + ); + + @override + Future setLastFid(String? fid) => _write( + (SharedPreferencesAsync prefs) => fid == null + ? prefs.remove(lastFidKey) + : prefs.setString(lastFidKey, fid), + ); + + @override + Future setServerRegistered(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(serverRegisteredKey, value), + ); + + Future _write( + Future Function(SharedPreferencesAsync prefs) write, + ) async { + try { + await write(_store); + } on Object { + // 쓰지 못했다 — 이번 실행은 메모리 값으로 이어 간다 + } + } +} + +/// 새 메일 알림 값을 두는 곳 — 시험은 메모리로 덮어쓴다 +final Provider pushPreferencesProvider = + Provider((Ref ref) => PrefsPushPreferences()); diff --git a/flutter/lib/core/push/push_registration.dart b/flutter/lib/core/push/push_registration.dart index a838722..e026a45 100644 --- a/flutter/lib/core/push/push_registration.dart +++ b/flutter/lib/core/push/push_registration.dart @@ -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 register(); - /// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다) + /// 세션이 끝났다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다. + /// 서버는 부르지 않는다(인증 토큰이 이미 없다 — 서버 줄은 logout · 세션 끝이 함께 지운다). 짧은 한도 안에서만 기다리고 실패는 삼킨다 Future 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 register() async {} +/// 판을 읽지 못한 기기(거의 없다)가 보내는 앱 판 — 서버 칸의 모양(`숫자.숫자.숫자`)에 맞춘 값 +const String kUnknownAppVersion = '0.0.0'; - @override - Future 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 put({ + required String fid, + required String appVersion, + required bool silent, + }) async { + await _dio.put( + ApiPaths.pushRegistration, + data: { + 'fid': fid, + 'platform': kPushPlatform, + 'appVersion': appVersion, + }, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// 등록을 지운다 — 실패는 `DioException` + Future delete({required bool silent}) async { + await _dio.delete( + ApiPaths.pushRegistration, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// "봤음" — 알리지 않는다. 실패는 `DioException` + Future markSeen() async { + await _dio.delete( + ApiPaths.pushRegistrationUnseen, + options: ApiOptions.silent(), + ); + } } -/// 푸시 기기 등록 +/// 새 메일 알림 등록 API +final Provider pushRegistrationRepositoryProvider = + Provider( + (Ref ref) => PushRegistrationRepository(ref.watch(dioProvider)), + ); + +/// 세션 노티파이어가 부르는 기기 등록 — 새 메일 알림 컨트롤러다(시험은 기록하는 가짜로 덮어쓴다) final Provider pushRegistrarProvider = Provider( - (Ref ref) => const NoopPushRegistrar(), + (Ref ref) => ref.watch(pushControllerProvider.notifier), ); diff --git a/flutter/lib/core/push/push_state.dart b/flutter/lib/core/push/push_state.dart new file mode 100644 index 0000000..2e2b3c4 --- /dev/null +++ b/flutter/lib/core/push/push_state.dart @@ -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, +} diff --git a/flutter/lib/core/push/push_target.dart b/flutter/lib/core/push/push_target.dart index 49d1385..7fb7ee1 100644 --- a/flutter/lib/core/push/push_target.dart +++ b/flutter/lib/core/push/push_target.dart @@ -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 data) { final Object? name = data[moduleKey]; final AppModuleId? module = AppModuleId.fromName( diff --git a/flutter/lib/core/push/push_texts.dart b/flutter/lib/core/push/push_texts.dart new file mode 100644 index 0000000..d989bd4 --- /dev/null +++ b/flutter/lib/core/push/push_texts.dart @@ -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을 찾아 알림을 켜 주세요.'; +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 8a0784b..53c6d40 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -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> appModulesProvider = Provider>( (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 routerProvider = Provider((Ref ref) { final List modules = ref.watch(appModulesProvider); final List enabled = enabledAppModules(modules); @@ -42,22 +47,28 @@ final Provider routerProvider = Provider((Ref ref) { settingsSections: moduleSettingsSections(moduleRoutes), ); - final _SessionChanges sessionChanges = _SessionChanges(); - ref.listen>( - sessionProvider, - (AsyncValue? previous, AsyncValue next) => - sessionChanges.notify(), - ); + final _RouteChanges routeChanges = _RouteChanges(); + ref + ..listen>( + sessionProvider, + (AsyncValue? previous, AsyncValue next) => + routeChanges.notify(), + ) + ..listen( + 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 routerProvider = Provider((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 routerProvider = Provider((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(); } diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 808e4c3..4355327 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -20,4 +20,7 @@ abstract final class AppRoutes { /// 제한된 세션 — 앱이 모르는 제한 static const String restricted = '/login/restricted'; + + /// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도) + static const String update = '/update'; } diff --git a/flutter/lib/core/router/push_navigation.dart b/flutter/lib/core/router/push_navigation.dart new file mode 100644 index 0000000..ae30eca --- /dev/null +++ b/flutter/lib/core/router/push_navigation.dart @@ -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 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(pushEventsProvider, ( + PushEvent? previous, + PushEvent? next, + ) { + if (next != null && next.opened) openPending(); + }) + ..listen(pushControllerProvider, (PushState? _, PushState _) {}); + // 라우터보다 먼저 온 누른 알림 — 라우터를 만든 뒤에 옮긴다(만드는 중에는 옮기지 않는다) + scheduleMicrotask(openPending); +} diff --git a/flutter/lib/core/router/session_redirect.dart b/flutter/lib/core/router/session_redirect.dart index f10dfbe..5ca0cbf 100644 --- a/flutter/lib/core/router/session_redirect.dart +++ b/flutter/lib/core/router/session_redirect.dart @@ -10,16 +10,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// /// | 세션 | 화면 | /// | --- | --- | +/// | (세션과 상관없이) 앱을 업데이트해야 함([updateRequired]) | 업데이트 — 로그인 전 화면에서도(옛 앱이 로그인 API 를 잘못 부르지 않게) | /// | 되살리는 중 · 되살리기 실패 | 시작 | /// | 로그아웃 | 로그인 | /// | 코드 단계 | 코드 | /// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 | /// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 | +/// +/// 업데이트가 풀리면(서버가 최소 판을 내림) 업데이트 화면은 세션이 고른 화면으로 돌아간다(그 경로는 로그인 전 화면도 모듈도 아니다). String? resolveSessionRedirect( AsyncValue session, String location, { List 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); diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index c51f380..8c09a32 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -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); } diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index f093048..cdf5dc3 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -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; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 0f4e81f..5ff7e50 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -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 states) => + states.contains(WidgetState.disabled); + bool on(Set states) => states.contains(WidgetState.selected); + return SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.switchThumbDisabled; + return on(states) ? scheme.onPrimary : scheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.surfaceMuted; + return on(states) ? scheme.primary : scheme.surface; + }), + trackOutlineColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.borderStrong; + return on(states) ? scheme.primary : scheme.outline; + }), + trackOutlineWidth: const WidgetStatePropertyAll( + AppSize.switchOutlineWidth, + ), + ); + } + static OutlineInputBorder _inputBorder( Color color, { double width = AppSize.borderWidth, diff --git a/flutter/lib/features/app_update/data/app_store_launcher.dart b/flutter/lib/features/app_update/data/app_store_launcher.dart new file mode 100644 index 0000000..6853542 --- /dev/null +++ b/flutter/lib/features/app_update/data/app_store_launcher.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef StoreLaunch = Future Function(Uri url, LaunchMode mode); + +Future _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 open(String packageName) async { + final Uri market = Uri( + scheme: 'market', + host: 'details', + queryParameters: {'id': packageName}, + ); + if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) { + return true; + } + final Uri web = Uri.parse( + kPlayStoreWebDetails, + ).replace(queryParameters: {'id': packageName}); + return _tryLaunch(web, LaunchMode.externalApplication); + } + + Future _tryLaunch(Uri url, LaunchMode mode) async { + try { + return await _launch(url, mode); + } on Object { + return false; + } + } +} + +/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다 +final Provider appStoreLauncherProvider = + Provider((Ref ref) => const AppStoreLauncher()); diff --git a/flutter/lib/features/app_update/presentation/app_update_texts.dart b/flutter/lib/features/app_update/presentation/app_update_texts.dart new file mode 100644 index 0000000..bc27ef6 --- /dev/null +++ b/flutter/lib/features/app_update/presentation/app_update_texts.dart @@ -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을 찾아 업데이트해 주세요.'; +} diff --git a/flutter/lib/features/app_update/presentation/pages/app_update_page.dart b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart new file mode 100644 index 0000000..d63e1ed --- /dev/null +++ b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart @@ -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 createState() => _AppUpdatePageState(); +} + +class _AppUpdatePageState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + } + + Future _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()!; + final String? version = ref.watch(appVersionProvider); + return LoginBrandFrame( + introduction: false, + contentTopPadding: AppSpacing.s32, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s10, + children: [ + 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: [ + 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.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), + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index 8ddd681..1c096aa 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -319,8 +319,8 @@ class SessionNotifier extends AsyncNotifier { ); } - /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. - /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. + /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지운다. 그 뒤 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)와 서버 logout 을 + /// 함께 보낸다(서로 기다리지 않는다). 서버에 닿지 못해도 이 기기는 로그아웃 상태다. 알림 서버의 기기 줄은 부르지 않는다 — 서버 logout 이 세션과 함께 지운다. Future signOut({SignOutReason reason = SignOutReason.none}) async { _advanceEpoch(); _secret.clear(); @@ -424,6 +424,8 @@ class SessionNotifier extends AsyncNotifier { 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 { // 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다 // (계약 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 { } } + /// 이 기기의 새 메일 알림을 멈춘다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다(짧은 한도 · 실패는 삼킨다). + /// 서버의 기기 줄은 logout · 세션 끝이 세션과 함께 지운다 Future _unregisterPushQuietly() async { try { await _push.unregister().timeout(kPushUnregisterTimeout); } on Object { - // 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다 + // 못 푼 등록은 알림 컨트롤러가 기억해 다음 실행에 다시 푼다 } } @@ -556,6 +564,8 @@ class SessionNotifier extends AsyncNotifier { state = const AsyncData( SignedOut(reason: SignOutReason.expired), ); + // 이 기기의 알림을 멈춘다 — 서버 줄은 세션을 끝낸 서버가 지운다 + unawaited(_unregisterPushQuietly()); case SessionRefreshed(:final Map body): // 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다) if (current is! SignedIn) return; diff --git a/flutter/lib/features/auth/presentation/widgets/login_frame.dart b/flutter/lib/features/auth/presentation/widgets/login_frame.dart index d7a7ce7..29ecf83 100644 --- a/flutter/lib/features/auth/presentation/widgets/login_frame.dart +++ b/flutter/lib/features/auth/presentation/widgets/login_frame.dart @@ -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 children; + /// 띠에 큰 글 · 소개 글을 보이는가 — 거짓이면 로고만 + final bool introduction; + + /// 띠 아래 내용의 위 여백 + final double contentTopPadding; + @override Widget build(BuildContext context) { return _LoginScaffold( top: const _StatusBarStrip(), body: _LoginScroll( children: [ - 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: [ - 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: [ + 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, + ), ), - ), - ], - ), + ], + ), ], ), ), diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index b6ac4ae..f41be4f 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -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 { 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: [ + 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('mail-compose-area'), diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index eaba777..25290a7 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -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 { bool _closing = false; bool _announceSaved = false; + /// 바깥 이동(알림을 눌러 받은편지함으로 · 업데이트 화면)이 잃을 것이 있는 이 화면을 내렸다 — 내려가며 마저 저장한다(detach) + bool _closedOutside = false; + /// 나가기 확인 창이 열려 있다 — 그사이 저장이 끝나면 창을 닫고 떠난다 bool _askingLeave = false; bool _savedWhileAsking = false; @@ -122,7 +127,8 @@ class _MessageComposePageState extends ConsumerState { @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 { 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); diff --git a/flutter/lib/features/mail/presentation/providers/compose_controller.dart b/flutter/lib/features/mail/presentation/providers/compose_controller.dart index 8bb753c..724c0cc 100644 --- a/flutter/lib/features/mail/presentation/providers/compose_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/compose_controller.dart @@ -1546,6 +1546,7 @@ class ComposeController extends Notifier { } 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 { 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 removed = {..._replacedIds}; if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) { @@ -1611,6 +1639,57 @@ class ComposeController extends Notifier { bool _sendingNow = false; + /// 마지막 저장 실패의 코드 — 바깥 이동으로 내려간 뒤의 저장이 실패하면 그 코드의 사전 문구로 알린다 + String? _lastSaveFailure; + + /// 바깥 이동으로 내려갈 때 마저 저장할까 — 보내지 않았고 · 보내는 중이 아니고 · 세션이 그대로고 · 채운 양식이다 + bool get _keepsOnDetach => + ref.mounted && + !_sent && + !_sendingNow && + _sameSession() && + state.status == ComposeStatus.ready; + + /// 바깥 이동으로 내려간 쓰기 화면의 마지막 저장 — 쓰다 만 주소까지 칩으로 만들어 저장하고, 저장했으면(또는 이 화면이 저장한 적이 있으면) + /// "임시보관함에 저장했습니다.", 못 했으면 그 코드의 사전 문구를 전역 알림으로. 세션이 바뀌면 알리지 않는다 + Future _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 removed = {..._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] 는 보낸 뒤 알림의 단추(보내기 취소 · 보낸편지함 보기)가 쓴다. diff --git a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart index 0d0472c..dac20ad 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_state_watcher.dart @@ -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 mailStateWatcherProvider = /// - 처음 받은 값은 기준으로만 쓴다. 값이 바뀌면 [MailChange] 를 하나 올린다 — 목록은 불러온 만큼 다시 받아 맞추고, 메일함은 개수를 맞춘다. /// - 실패(`MAIL_001` · 연결 끊김)는 알리지 않고 다음 차례에 다시 묻는다. 401 은 인증 인터셉터의 세션 흐름을 그대로 탄다. /// - 이 앱의 목록이 메일을 옮기거나 되돌리면 [markChanged] 로 다른 목록에 알린다(그 목록이 보일 때 맞춘다). -/// - 푸시(새 메일 알림)는 다음 차례다. +/// - 새 메일 알림 소식(앱이 앞에 있을 때 온 알림 · 누른 알림 — `core/push/push_events.dart`)이 오면 목록이 보일 때 10초 간격을 건너뛰고 +/// 바로 묻는다(묻는 중이면 끝난 뒤 한 번). 안 보이면 보일 때 위 규칙대로다. class MailStateWatcher extends Notifier { final Set _viewers = {}; 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 { _timer?.cancel(); _timer = null; _asking = false; + _askAgain = false; _lastAskedAt = null; _lastState = null; _foreground = ref.read(appForegroundProvider); - ref.listen(appForegroundProvider, (bool? previous, bool next) { - final bool before = _active; - _foreground = next; - if (before != _active) _onActiveChanged(); - }); + ref + ..listen(appForegroundProvider, (bool? previous, bool next) { + final bool before = _active; + _foreground = next; + if (before != _active) _onActiveChanged(); + }) + ..listen(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 { ); } + /// 새 메일 알림이 왔다 — 보이는 목록이 있으면 바로 묻는다(묻는 중이면 끝난 뒤 한 번) + 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 { // 그사이 처음부터 다시 만들었으면(세션이 바뀜) 새 차례가 이어 간다 if (ref.mounted) { _asking = false; - _schedule(kMailStatePollInterval); + if (_askAgain) { + _askAgain = false; + unawaited(_ask()); + } else { + _schedule(kMailStatePollInterval); + } } } } diff --git a/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart b/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart new file mode 100644 index 0000000..8e25184 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/push_offer_line.dart @@ -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()!; + 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: [ + 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: [ + 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 _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 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; + } + } +} diff --git a/flutter/lib/features/settings/presentation/pages/settings_page.dart b/flutter/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 0000000..db6258a --- /dev/null +++ b/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -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 [], + this.sectionLocation, + super.key, + }); + + /// 오픈소스 라이선스 화면의 경로 + final String licensesLocation; + + /// 다른 모듈이 내놓은 설정 절 + final List sections; + + /// 모듈 절의 경로 — `/settings/` + final String Function(String path)? sectionLocation; + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + } + + Future _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()!; + 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: [ + _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) ...[ + const _SectionDivider(), + _LinkRow( + label: section.title, + onTap: sectionLocation == null + ? null + : () => unawaited( + context.push(sectionLocation(section.path)), + ), + ), + ], + const _SectionDivider(), + _Section( + title: SettingsTexts.about, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _VersionRow(version: version), + _LinkRow( + label: SettingsTexts.licenses, + onTap: () => + unawaited(context.push(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()!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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()!.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()!; + 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: [ + 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: [ + 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()!; + 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: [ + 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.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()!; + 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: [ + 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()!; + 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: [ + 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()!; + 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), + ); + } +} diff --git a/flutter/lib/features/settings/presentation/settings_texts.dart b/flutter/lib/features/settings/presentation/settings_texts.dart index 635fac8..a8d7c34 100644 --- a/flutter/lib/features/settings/presentation/settings_texts.dart +++ b/flutter/lib/features/settings/presentation/settings_texts.dart @@ -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 = '취소'; } diff --git a/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart b/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart new file mode 100644 index 0000000..061a4f2 --- /dev/null +++ b/flutter/lib/features/settings/presentation/widgets/settings_notification_row.dart @@ -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()!; + 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: [ + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: view.opensSettings ? AppSpacing.s8 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s2, + children: [ + // 이름 · 설명은 스위치가 함께 읽는다(두 번 읽히지 않게 글 자체는 빼 둔다) + 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: [ + Text(PushTexts.openSettings), + Icon(Icons.open_in_new, size: AppSize.iconTiny), + ], + ), + ), + ), + ); + } + + Future _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)), + ); + } +} diff --git a/flutter/lib/features/settings/settings_routes.dart b/flutter/lib/features/settings/settings_routes.dart index dc73748..7c5c9d2 100644 --- a/flutter/lib/features/settings/settings_routes.dart +++ b/flutter/lib/features/settings/settings_routes.dart @@ -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/`) 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) => [ 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: [ 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( - label: SettingsTexts.licenses, - location: SettingsRoutes.licenses, - ), - ], - ), - routes: [ - 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 sections; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return PlaceholderPage( - title: SettingsTexts.home, - links: [ - 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(); diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index ff0ecf3..62bb31d 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -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 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 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(), ), diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 441443b..73e1acb 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 463cc21..612d5f3 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -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 diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b793afa..b9e631d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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 diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 1e9af23..44aa28b 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -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 [ 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); }); }); diff --git a/flutter/test/core/app_config/app_config_test.dart b/flutter/test/core/app_config/app_config_test.dart new file mode 100644 index 0000000..7f430b4 --- /dev/null +++ b/flutter/test/core/app_config/app_config_test.dart @@ -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 [ + 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 { + 'minSupportedVersion': { + '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 { + 'minSupportedVersion': '1.0.0', + 'pushAvailable': 'yes', + }), + const AppConfig(), + ); + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': {'android': 'latest'}, + 'pushAvailable': false, + }), + const AppConfig(pushAvailable: false), + ); + expect( + AppConfig.fromJson(const { + 'minSupportedVersion': {'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({ + 'minSupportedVersion': {'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 refreshGate = Completer(); + h.server + ..on('POST', ApiPaths.appRefresh, (_) async { + await refreshGate.future; + return FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: h.clock.now), + }); + }) + ..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())) + ..on( + 'GET', + ApiPaths.appConfig, + (_) => FakeResponse.ok({'pushAvailable': true}), + ); + final ProviderContainer container = h.container(); + addTearDown(container.dispose); + final Future> me = container + .read(dioProvider) + .get(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({'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(), + ); + 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({ + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': true, + }); + h.server.on('GET', ApiPaths.appConfig, (_) { + requests++; + return respond(); + }); + await h.prepare(); + container = ProviderContainer( + overrides: [ + ...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 idle() async { + for (int turn = 0; turn < 40; turn++) { + await Future.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({ + 'minSupportedVersion': {'android': '1.0.1'}, + 'pushAvailable': true, + }); + await pumpEventsUntil(() => container.read(appUpdateRequiredProvider)); + + respond = () => FakeResponse.ok({ + 'minSupportedVersion': {'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({'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); + }); + }); +} diff --git a/flutter/test/core/push/push_app_test.dart b/flutter/test/core/push/push_app_test.dart new file mode 100644 index 0000000..ec09c75 --- /dev/null +++ b/flutter/test/core/push/push_app_test.dart @@ -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 inlineQuote(ComposeQuoteRequest request) async => + initialQuoteOf(request); + + List pushOverrides(FakePushMessaging fake) => [ + 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([ + composeQuoteBuilderProvider.overrideWithValue(inlineQuote), + ...pushOverrides(messaging), + ]); + h.server.serverTime = kTestNow; + }); + + Future 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(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(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('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(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('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, ['MAIL_001']); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('세션에 제한이 생겨(갱신 응답) 제한 단계로 옮겨 내려가면 알리지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('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(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({ + 'minSupportedVersion': {'android': minimum}, + 'pushAvailable': true, + }), + ); + await start(tester); + expect(h.location(), MailRoutes.home); + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('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({ + 'minSupportedVersion': {'android': '2.0.0'}, + 'pushAvailable': true, + }), + ); + await login.pump( + tester, + overrides: [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 gate = Completer(); + 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); + }); + }); +} diff --git a/flutter/test/core/push/push_controller_test.dart b/flutter/test/core/push/push_controller_test.dart new file mode 100644 index 0000000..13772be --- /dev/null +++ b/flutter/test/core/push/push_controller_test.dart @@ -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 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 idle() async { + for (int turn = 0; turn < 60; turn++) { + await Future.delayed(Duration.zero); + } + } + + Future until(bool Function() condition, String reason) => + pumpEventsUntil(condition, reason: reason); + + Future 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, { + '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 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 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({ + '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 = { + 'minSupportedVersion': {'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({ + '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, ['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({ + '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, ['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(); + 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 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 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(); + + final Future 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, ['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, ['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, ['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, ['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); + }); +} diff --git a/flutter/test/core/push/push_mapping_test.dart b/flutter/test/core/push/push_mapping_test.dart new file mode 100644 index 0000000..fb0ea23 --- /dev/null +++ b/flutter/test/core/push/push_mapping_test.dart @@ -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 [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: {'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 { + 'module': 'mail', + 'kind': 'session-ended', + }).isNewMail, + isFalse, + ); + expect( + PushMessage.fromData(const { + 'module': 'mail', + }).isNewMail, + isTrue, + ); + final PushMessage unknown = PushMessage.fromData(const { + 'module': 'chat', + 'kind': 'new-mail', + }); + expect(unknown.target, isNull); + expect(unknown.isNewMail, isFalse); + expect( + PushMessage.fromData(const { + 'module': 'mail', + 'kind': '', + }).kind, + isNull, + ); + }); + }); + + group('알림창 채널 (Kotlin NewMailNotifications 와 짝)', () { + const MethodChannel channel = PlatformNotificationSystem.channel; + final List calls = []; + + 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( + (_) => { + 'appEnabled': true, + 'channelEnabled': false, + 'sdkInt': 34, + }, + ); + expect( + await const PlatformNotificationSystem().status(), + const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ), + ); + + answer((_) => {'appEnabled': 'yes', 'sdkInt': '34'}); + expect( + await const PlatformNotificationSystem().status(), + const NotificationStatus(appEnabled: true, channelEnabled: true), + ); + expect(calls, ['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, ['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); + }); +} diff --git a/flutter/test/core/push/push_registration_repository_test.dart b/flutter/test/core/push/push_registration_repository_test.dart new file mode 100644 index 0000000..21dc8e8 --- /dev/null +++ b/flutter/test/core/push/push_registration_repository_test.dart @@ -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({ + '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, { + '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, ['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); + }, + ); +} diff --git a/flutter/test/core/push/push_state_test.dart b/flutter/test/core/push/push_state_test.dart new file mode 100644 index 0000000..b7055cc --- /dev/null +++ b/flutter/test/core/push/push_state_test.dart @@ -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); + }); + }); +} diff --git a/flutter/test/core/router/session_redirect_test.dart b/flutter/test/core/router/session_redirect_test.dart index 3de0095..5a312af 100644 --- a/flutter/test/core/router/session_redirect_test.dart +++ b/flutter/test/core/router/session_redirect_test.dart @@ -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, String)> cases = + <(AsyncValue, String)>[ + (const AsyncLoading(), AppRoutes.start), + (const AsyncData(SignedOut()), AppRoutes.login), + ( + const AsyncData( + CodeRequired('ara.kim@arasystem.it.kr'), + ), + AppRoutes.loginCode, + ), + ( + _signedIn(SessionRestriction.passwordChange), + AppRoutes.passwordChange, + ), + (_signedIn(), homeLocation), + (_signedIn(), '/mail/compose'), + (_signedIn(), '/settings/licenses'), + ]; + for (final (AsyncValue 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(SignedOut()), + AppRoutes.update, + ), + AppRoutes.login, + ); + expect( + resolveSessionRedirect( + const AsyncLoading(), + AppRoutes.update, + ), + AppRoutes.start, + ); + expect( + resolveSessionRedirect( + _signedIn(SessionRestriction.twoFactorSetup), + AppRoutes.update, + ), + AppRoutes.twoFactorSetup, + ); + }); + }); } diff --git a/flutter/test/features/app_update/app_update_page_test.dart b/flutter/test/features/app_update/app_update_page_test.dart new file mode 100644 index 0000000..1d3b18e --- /dev/null +++ b/flutter/test/features/app_update/app_update_page_test.dart @@ -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 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({ + 'minSupportedVersion': {'android': '2.0.0'}, + 'pushAvailable': true, + }), + ); + }); + + Future pump(WidgetTester tester, {String? packageName = _package}) => + h.pump( + tester, + overrides: [ + 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); + }); +} diff --git a/flutter/test/features/auth/login_pages_test.dart b/flutter/test/features/auth/login_pages_test.dart index 58eaddc..efdeb81 100644 --- a/flutter/test/features/auth/login_pages_test.dart +++ b/flutter/test/features/auth/login_pages_test.dart @@ -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 || + // 앱 설정(최소 판 · 알림을 쓸 수 있는지)과 새 메일 알림 — 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; /// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다 diff --git a/flutter/test/features/auth/session_provider_test.dart b/flutter/test/features/auth/session_provider_test.dart index 4e7f5f8..4c9295f 100644 --- a/flutter/test/features/auth/session_provider_test.dart +++ b/flutter/test/features/auth/session_provider_test.dart @@ -493,6 +493,78 @@ void main() { }); }); + group('새 메일 알림 (기기 등록)', () { + Future signedIn() async { + await start(refreshToken: 'refresh-1'); + refreshGives(2); + h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + expect(await settled(), isA()); + 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([])); + + await errorOf(container.read(dioProvider).get('/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 signedIn() async { await start(refreshToken: 'refresh-1'); diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index fbbc95d..a3c7aa5 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -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); diff --git a/flutter/test/features/mail/push_offer_line_test.dart b/flutter/test/features/mail/push_offer_line_test.dart new file mode 100644 index 0000000..aafe734 --- /dev/null +++ b/flutter/test/features/mail/push_offer_line_test.dart @@ -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 = { + 'minSupportedVersion': {'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); + }); +} diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart new file mode 100644 index 0000000..1432494 --- /dev/null +++ b/flutter/test/features/settings/settings_page_test.dart @@ -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({'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 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 [ + 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( + 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(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 states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + /// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다) + Future visitPhoneSettings(WidgetTester tester) async { + lifecycle(tester, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]); + await tester.pump(); + lifecycle(tester, const [ + 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(); + + 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(); + + 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, ['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, ['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 = { + 'minSupportedVersion': {'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); + }); + }); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart index 1e171eb..91b15cb 100644 --- a/flutter/test/support/fakes.dart +++ b/flutter/test/support/fakes.dart @@ -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])는 그대로 탄다 diff --git a/flutter/test/support/login_fakes.dart b/flutter/test/support/login_fakes.dart index d4ece5a..6644024 100644 --- a/flutter/test/support/login_fakes.dart +++ b/flutter/test/support/login_fakes.dart @@ -140,6 +140,7 @@ class LoginHarness { WidgetTester tester, { String? refreshToken, List observers = const [], + List overrides = const [], }) async { tester.view.physicalSize = const Size(390 * 3, 1500 * 3); tester.view.devicePixelRatio = 3; @@ -173,6 +174,7 @@ class LoginHarness { overrides: [ ...base.overrides, authenticatorLauncherProvider.overrideWithValue(launcher), + ...overrides, ], child: const App(), ), diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index b1f0cdb..999828f 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -140,6 +140,9 @@ class FakeMailBackend { FakeResponse? deletionFailure; FakeResponse? stateFailure; + /// 있으면 다음 새 메일 확인 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? stateGate; + /// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce]) Completer? 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? gate = stateGate; + stateGate = null; + await gate?.future; final FakeResponse? failure = stateFailure; if (failure != null) return failure; return FakeResponse.ok({'state': state}); diff --git a/flutter/test/support/provider_harness.dart b/flutter/test/support/provider_harness.dart index 83fc5b1..d2c8603 100644 --- a/flutter/test/support/provider_harness.dart +++ b/flutter/test/support/provider_harness.dart @@ -25,6 +25,9 @@ class ProviderHarness { late final RecordingNotifier notifier = RecordingNotifier(clock); late final RecordingPushRegistrar push = RecordingPushRegistrar(store); + /// 거짓이면 기기 등록을 기록하는 가짜로 바꾸지 않는다 — 세션이 진짜 새 메일 알림 컨트롤러를 부른다(알림 화면 시험) + bool recordPush = true; + /// ProviderScope · ProviderContainer 에 넣을 덮어쓰기 List get overrides => [ 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), ]; /// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너 diff --git a/flutter/test/support/push_fakes.dart b/flutter/test/support/push_fakes.dart new file mode 100644 index 0000000..3045c67 --- /dev/null +++ b/flutter/test/support/push_fakes.dart @@ -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? registerGate; + Completer? unregisterGate; + + /// 꺼져 있던 앱을 띄운 알림 + PushMessage? initial; + + /// 부른 차례 — 'register' · 'unregister' · 'requestPermission' + final List calls = []; + + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.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 permission() async => current; + + @override + Future requestPermission() async { + permissionRequests++; + calls.add('requestPermission'); + final PushPermission? chosen = answer; + if (chosen != null) current = chosen; + return current; + } + + @override + Future 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 unregister() async { + calls.add('unregister'); + onUnregister?.call(); + await unregisterGate?.future; + final Object? error = unregisterError; + if (error != null) throw error; + } + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future 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 status() async { + statusCalls++; + return current; + } + + @override + Future openSettings() async { + settingsOpened++; + return opens; + } + + @override + Future clearNewMail() async => clears++; +} + +/// 메모리 새 메일 알림 값 — 쓴 값을 [values] 로 본다 +class MemoryPushPreferences implements PushPreferences { + MemoryPushPreferences([this.values = PushPreferenceValues.initial]); + + PushPreferenceValues values; + + /// 두면 읽기가 이것이 끝날 때까지 기다린다 + Completer? loadGate; + + @override + Future 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 setEnabled(bool value) async => values = _with(enabled: value); + + @override + Future setOfferDismissed(bool value) async => + values = _with(offerDismissed: value); + + @override + Future setPendingUnregister(bool value) async => + values = _with(pendingUnregister: value); + + @override + Future setLastFid(String? fid) async => values = _with(lastFid: fid); + + @override + Future 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 body, String? bearer})> puts = + <({Map body, String? bearer})>[]; + final List deletes = []; + final List seen = []; + final List configRequests = []; + + /// 두면 그 응답으로 답한다(실패 시험) + FakeResponse? putFailure; + FakeResponse? deleteFailure; + Completer? putGate; + Completer? deleteGate; + + /// 앱 설정의 `data` — [configResponse] 가 있으면 그것으로 답한다 + Map config = { + 'minSupportedVersion': {'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 + ? Map.of(data) + : {}, + bearer: bearerTokenOf(options), + )); + await putGate?.future; + return putFailure ?? + FakeResponse.ok({ + '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 extra = []; + + List get overrides => [ + 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 prepare({String? refreshToken}) async { + if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken; + push.install(); + server + ..on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ) + ..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())) + ..on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await store.prepare(); + } +} diff --git a/flutter/test/support/push_screen_harness.dart b/flutter/test/support/push_screen_harness.dart new file mode 100644 index 0000000..3e3d95e --- /dev/null +++ b/flutter/test/support/push_screen_harness.dart @@ -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([ + 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 pump(WidgetTester tester, {String? location}) async { + push.install(); + app.mail.seed(kInboxId, 2); + await app.pump(tester, location: location); + await settle(tester); + } + + /// 설정 탭을 연다 + Future 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 settle(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 2048c45..0e0afee 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index e17c858..9e4353b 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_core flutter_secure_storage_windows url_launcher_windows )