feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭

앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다.

- firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다.
  google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다).
- 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다.
- 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다
  (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다).
- 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다.
- 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다).
- 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다.
- 시험 932 → 1,056.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:46:11 +09:00
parent bf725ae96d
commit e03eda1fd3
74 changed files with 7067 additions and 229 deletions
+4
View File
@@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다).
# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림")
app/google-services.json
+19
View File
@@ -42,3 +42,22 @@ android {
flutter {
source = "../.."
}
// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림").
// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다.
// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게.
// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build,
// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다.
// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다.
val firebaseConfig = file("google-services.json")
if (firebaseConfig.exists()) {
apply(plugin = "com.google.gms.google-services")
} else {
gradle.taskGraph.whenReady {
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
throw GradleException(
"android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").",
)
}
}
}
@@ -1,6 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- API 서버에 닿으려면 release 판에도 있어야 한다 (debug · profile 매니페스트에만 있던 것을 옮겼다) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!--
새 메일 알림 — 안드로이드 13 이상은 알림을 보이려면 이 권한을 직원이 허락해야 한다(firebase_messaging 매니페스트에도 있다 — 뜻을 보이려고 적는다).
묻는 창은 [알림 켜기]를 누를 때만 띄운다(core/push/push_controller.dart)
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 광고 ID 를 쓰지 않는다 — 라이브러리 매니페스트가 넣어도 뺀다(Play 의 광고 ID 선언을 "아니요"로) -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
<!--
백업 끔: 리프레시 토큰은 flutter_secure_storage 가 이 기기의 Keystore 키로 암호화해 둔다.
Google 드라이브 백업 · 기기 옮기기로 다른 기기에 복원되면 키가 없어 읽지 못하고(플러그인 README),
@@ -49,6 +57,24 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/downloaded_file_paths" />
</provider>
<!--
새 메일 알림(FCM) — 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다. 서버 메시지에 없는 값을 여기서 채운다.
채널: 짝 — NewMailNotifications.kt(채널을 만든다) · 서버 channel_id. 아이콘 · 색은 서버가 싣지 않아 늘 이 기본값이다
(색: 짝 — Dart AppColors.brand #0E7C86. 작은 아이콘을 바꾸려면 drawable 의 ic_stat_ara 그림만 바꾼다 — flutter/README "앱 아이콘 · 알림 아이콘")
-->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/new_mail_channel_id" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_ara" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- 로그인 전에는 FCM 에 등록하지 않는다 — 앱이 register() 를 부를 때만(core/push/push_controller.dart) -->
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -28,6 +28,8 @@ import java.util.Locale
* 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다.
* 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`.
* 경로 · 주소 · 파일 이름은 로그에 남기지 않는다.
*
* 알림 채널(`kr.it.arasystem.app/notifications`)은 [NewMailNotifications] 가 맡는다 — 새 메일 알림 채널도 화면을 만들 때 그것이 만든다.
*/
class MainActivity : FlutterActivity() {
/** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */
@@ -49,6 +51,11 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 새 메일 알림 — 채널은 첫 알림보다 먼저 있어야 한다(서버의 기기 줄은 앱을 연 뒤에야 생긴다)
NewMailNotifications(this).apply {
createChannel()
register(flutterEngine.dartExecutor.binaryMessenger)
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
@@ -0,0 +1,126 @@
package kr.it.arasystem.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* 새 메일 알림의 채널과 알림창 — 채널 `kr.it.arasystem.app/notifications`.
*
* **짝: Dart `core/push/notification_system.dart` 의 `PlatformNotificationSystem`** — 채널 이름 · 부르는 이름 · 답의 칸과 값(한쪽을 고치면 둘 다).
*
* - 알림 채널 `new_mail`(짝: 서버 `channel_id` · 매니페스트의 기본 채널 — 값은 `R.string.new_mail_channel_id`)을 앱을 열 때마다 만든다.
* 중요도 HIGH(소리 · 머리 알림) — 한 번 만든 채널의 중요도는 앱이 바꾸지 못한다(직원이 바꾼다). 이름 · 설명은 다시 만들 때 바뀐다.
* 알림은 앱이 뒤에 있거나 꺼져 있을 때 FCM 이 이 채널로 띄운다(앱 코드는 돌지 않는다).
* - `status` → `{appEnabled, channelEnabled, sdkInt}` — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다.
* - `openSettings` → 새 메일 채널만 꺼졌으면 그 채널 설정, 아니면 이 앱의 알림 설정(24 · 25 는 앱 정보). 답은 opened · failed.
* - `clearNewMail` → 알림창의 "새 메일 N통"(tag `new-mail` — 짝: 서버 tag. FCM 은 tag 를 주면 id 0 으로 띄운다)만 지운다.
*
* 주소 · 토큰 · FID 는 다루지도 로그에 남기지도 않는다.
*/
class NewMailNotifications(private val activity: Activity) {
private val manager = NotificationManagerCompat.from(activity)
private val channelId: String
get() = activity.getString(R.string.new_mail_channel_id)
/** 채널을 만든다 — 이미 있으면 이름 · 설명만 바뀐다(채널이 없는 판은 아무것도 하지 않는다) */
fun createChannel() {
val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(activity.getString(R.string.new_mail_channel_name))
.setDescription(activity.getString(R.string.new_mail_channel_description))
.build()
manager.createNotificationChannel(channel)
}
fun register(messenger: BinaryMessenger) {
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
STATUS_METHOD -> result.success(status())
OPEN_SETTINGS_METHOD -> result.success(openSettings())
CLEAR_NEW_MAIL_METHOD -> {
clearNewMail()
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun status(): Map<String, Any> = mapOf(
KEY_APP_ENABLED to manager.areNotificationsEnabled(),
KEY_CHANNEL_ENABLED to isChannelEnabled(),
KEY_SDK_INT to Build.VERSION.SDK_INT,
)
/** 새 메일 채널이 켜져 있다 — 채널이 없는 판(24 · 25)이거나 채널을 아직 만들지 않았으면 참 */
private fun isChannelEnabled(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true
val channel = manager.getNotificationChannelCompat(channelId) ?: return true
return channel.importance != NotificationManagerCompat.IMPORTANCE_NONE
}
/** 휴대폰 설정을 연다 — 가장 알맞은 화면부터, 열리지 않으면 다음 화면 */
private fun openSettings(): String {
val packageName = activity.packageName
val intents = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (manager.areNotificationsEnabled() && !isChannelEnabled()) {
add(
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId),
)
}
add(
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName),
)
}
add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)))
}
for (intent in intents) {
try {
activity.startActivity(intent)
return RESULT_OPENED
} catch (error: ActivityNotFoundException) {
// 이 화면이 없는 기기 — 다음 화면을 연다
} catch (error: SecurityException) {
// 열 수 없다 — 다음 화면을 연다
}
}
return RESULT_FAILED
}
private fun clearNewMail() {
manager.cancel(NEW_MAIL_TAG, FCM_NOTIFICATION_ID)
}
private companion object {
/** 짝: Dart `PlatformNotificationSystem.channel` */
const val CHANNEL = "kr.it.arasystem.app/notifications"
const val STATUS_METHOD = "status"
const val OPEN_SETTINGS_METHOD = "openSettings"
const val CLEAR_NEW_MAIL_METHOD = "clearNewMail"
// 짝: Dart `PlatformNotificationSystem` 의 appEnabledKey · channelEnabledKey · sdkIntKey · openedResult
const val KEY_APP_ENABLED = "appEnabled"
const val KEY_CHANNEL_ENABLED = "channelEnabled"
const val KEY_SDK_INT = "sdkInt"
const val RESULT_OPENED = "opened"
const val RESULT_FAILED = "failed"
/** 짝: 서버가 보내는 알림의 tag(`android.notification.tag`) — "새 메일 N통" · "알림이 멈췄습니다"가 함께 쓴다 */
const val NEW_MAIL_TAG = "new-mail"
/** FCM 은 tag 를 준 알림을 id 0 으로 띄운다 */
const val FCM_NOTIFICATION_ID = 0
}
}
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 새 메일 알림의 강조 색(알림창의 작은 아이콘 둘레 · 앱 이름) — 짝: Dart AppColors.brand(#0E7C86) -->
<color name="notification_accent">#0E7C86</color>
<!-- 앱 아이콘 바탕 — 남색(웹의 탭 · 홈 화면 아이콘과 같은 #0B3640 — 짝: Dart AppColors.brandSurface) -->
<color name="ic_launcher_background">#0B3640</color>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 새 메일 알림 채널 id — 짝: 서버 channel_id · 매니페스트의 기본 채널(default_notification_channel_id) · NewMailNotifications.kt -->
<string name="new_mail_channel_id" translatable="false">new_mail</string>
<!--
휴대폰 설정의 알림 카테고리에 보이는 채널 이름 · 설명 — 앱 보드 App1Push ⑫ 의 글 그대로(화면 문구다 — 디자인 README 「화면 문구」).
채널을 다시 만들 때 이름 · 설명은 바뀐다(중요도는 바뀌지 않는다)
-->
<string name="new_mail_channel_name">새 메일</string>
<string name="new_mail_channel_description">새 메일이 오면 알려 드립니다.</string>
</resources>
+3
View File
@@ -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")