timsquad 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.ko.md +288 -0
  2. package/README.md +158 -151
  3. package/dist/commands/compile.d.ts +3 -0
  4. package/dist/commands/compile.d.ts.map +1 -0
  5. package/dist/commands/compile.js +170 -0
  6. package/dist/commands/compile.js.map +1 -0
  7. package/dist/commands/daemon.d.ts.map +1 -1
  8. package/dist/commands/daemon.js +95 -5
  9. package/dist/commands/daemon.js.map +1 -1
  10. package/dist/commands/full.js +1 -0
  11. package/dist/commands/full.js.map +1 -1
  12. package/dist/commands/git/pr.js +6 -5
  13. package/dist/commands/git/pr.js.map +1 -1
  14. package/dist/commands/git/release.js +2 -7
  15. package/dist/commands/git/release.js.map +1 -1
  16. package/dist/commands/improve.js +2 -2
  17. package/dist/commands/improve.js.map +1 -1
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +12 -3
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/log.d.ts.map +1 -1
  22. package/dist/commands/log.js +2 -2
  23. package/dist/commands/log.js.map +1 -1
  24. package/dist/commands/metrics.d.ts.map +1 -1
  25. package/dist/commands/metrics.js +6 -2
  26. package/dist/commands/metrics.js.map +1 -1
  27. package/dist/commands/retro.js +8 -8
  28. package/dist/commands/retro.js.map +1 -1
  29. package/dist/commands/session.js +3 -3
  30. package/dist/commands/session.js.map +1 -1
  31. package/dist/commands/skills.d.ts +12 -0
  32. package/dist/commands/skills.d.ts.map +1 -0
  33. package/dist/commands/skills.js +228 -0
  34. package/dist/commands/skills.js.map +1 -0
  35. package/dist/commands/status.js +1 -1
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/commands/upgrade.d.ts.map +1 -1
  38. package/dist/commands/upgrade.js +23 -1
  39. package/dist/commands/upgrade.js.map +1 -1
  40. package/dist/daemon/entry.js +3 -3
  41. package/dist/daemon/entry.js.map +1 -1
  42. package/dist/daemon/event-queue.d.ts.map +1 -1
  43. package/dist/daemon/event-queue.js +2 -2
  44. package/dist/daemon/event-queue.js.map +1 -1
  45. package/dist/daemon/index.d.ts +4 -2
  46. package/dist/daemon/index.d.ts.map +1 -1
  47. package/dist/daemon/index.js +214 -52
  48. package/dist/daemon/index.js.map +1 -1
  49. package/dist/daemon/jsonl-watcher.d.ts +1 -0
  50. package/dist/daemon/jsonl-watcher.d.ts.map +1 -1
  51. package/dist/daemon/jsonl-watcher.js.map +1 -1
  52. package/dist/daemon/meta-cache.d.ts +1 -0
  53. package/dist/daemon/meta-cache.d.ts.map +1 -1
  54. package/dist/daemon/meta-cache.js +9 -0
  55. package/dist/daemon/meta-cache.js.map +1 -1
  56. package/dist/daemon/session-notes.d.ts +33 -0
  57. package/dist/daemon/session-notes.d.ts.map +1 -0
  58. package/dist/daemon/session-notes.js +74 -0
  59. package/dist/daemon/session-notes.js.map +1 -0
  60. package/dist/daemon/session-state.d.ts +27 -0
  61. package/dist/daemon/session-state.d.ts.map +1 -0
  62. package/dist/daemon/session-state.js +165 -0
  63. package/dist/daemon/session-state.js.map +1 -0
  64. package/dist/daemon/shutdown.d.ts.map +1 -1
  65. package/dist/daemon/shutdown.js +9 -1
  66. package/dist/daemon/shutdown.js.map +1 -1
  67. package/dist/index.js +4 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/lib/agent-generator.d.ts +4 -0
  70. package/dist/lib/agent-generator.d.ts.map +1 -1
  71. package/dist/lib/agent-generator.js +52 -3
  72. package/dist/lib/agent-generator.js.map +1 -1
  73. package/dist/lib/compile-rules.d.ts +66 -0
  74. package/dist/lib/compile-rules.d.ts.map +1 -0
  75. package/dist/lib/compile-rules.js +114 -0
  76. package/dist/lib/compile-rules.js.map +1 -0
  77. package/dist/lib/compiler.d.ts +105 -0
  78. package/dist/lib/compiler.d.ts.map +1 -0
  79. package/dist/lib/compiler.js +368 -0
  80. package/dist/lib/compiler.js.map +1 -0
  81. package/dist/lib/config.d.ts +1 -0
  82. package/dist/lib/config.d.ts.map +1 -1
  83. package/dist/lib/config.js +8 -1
  84. package/dist/lib/config.js.map +1 -1
  85. package/dist/lib/project.d.ts.map +1 -1
  86. package/dist/lib/project.js +8 -3
  87. package/dist/lib/project.js.map +1 -1
  88. package/dist/lib/skill-generator.d.ts.map +1 -1
  89. package/dist/lib/skill-generator.js +22 -1
  90. package/dist/lib/skill-generator.js.map +1 -1
  91. package/dist/lib/template.d.ts.map +1 -1
  92. package/dist/lib/template.js +6 -0
  93. package/dist/lib/template.js.map +1 -1
  94. package/dist/types/config.d.ts +1 -0
  95. package/dist/types/config.d.ts.map +1 -1
  96. package/dist/types/config.js +12 -1
  97. package/dist/types/config.js.map +1 -1
  98. package/dist/types/project.d.ts +1 -1
  99. package/dist/types/project.d.ts.map +1 -1
  100. package/dist/types/project.js +2 -0
  101. package/dist/types/project.js.map +1 -1
  102. package/package.json +4 -4
  103. package/templates/base/agents/base/tsq-architect.md +2 -2
  104. package/templates/base/agents/overlays/domain/mobile/_common.md +13 -0
  105. package/templates/base/knowledge/checklists/plan-quality.md +31 -0
  106. package/templates/base/knowledge/checklists/stability-verification.md +14 -0
  107. package/templates/base/skills/controller/SKILL.md +111 -0
  108. package/templates/base/skills/controller/references/README.md +35 -0
  109. package/templates/base/skills/controller/rules/README.md +18 -0
  110. package/templates/base/skills/mobile/dart/SKILL.md +69 -0
  111. package/templates/base/skills/mobile/dart/rules/async-patterns.md +112 -0
  112. package/templates/base/skills/mobile/dart/rules/code-style.md +96 -0
  113. package/templates/base/skills/mobile/dart/rules/null-safety.md +84 -0
  114. package/templates/base/skills/mobile/dart/rules/type-system.md +111 -0
  115. package/templates/base/skills/mobile/flutter/SKILL.md +89 -0
  116. package/templates/base/skills/mobile/flutter/ci-cd/SKILL.md +82 -0
  117. package/templates/base/skills/mobile/flutter/ci-cd/references/ci-cd-pipeline.md +314 -0
  118. package/templates/base/skills/mobile/flutter/ci-cd/rules/code-signing.md +106 -0
  119. package/templates/base/skills/mobile/flutter/ci-cd/rules/codemagic-setup.md +116 -0
  120. package/templates/base/skills/mobile/flutter/ci-cd/rules/fastlane-setup.md +105 -0
  121. package/templates/base/skills/mobile/flutter/ci-cd/rules/github-actions.md +112 -0
  122. package/templates/base/skills/mobile/flutter/ci-cd/rules/store-deployment.md +106 -0
  123. package/templates/base/skills/mobile/flutter/ci-cd/rules/versioning.md +107 -0
  124. package/templates/base/skills/mobile/flutter/i18n/SKILL.md +78 -0
  125. package/templates/base/skills/mobile/flutter/i18n/references/i18n-architecture.md +225 -0
  126. package/templates/base/skills/mobile/flutter/i18n/rules/arb-files.md +182 -0
  127. package/templates/base/skills/mobile/flutter/i18n/rules/locale-switching.md +226 -0
  128. package/templates/base/skills/mobile/flutter/i18n/rules/localization-setup.md +137 -0
  129. package/templates/base/skills/mobile/flutter/i18n/rules/plural-gender.md +159 -0
  130. package/templates/base/skills/mobile/flutter/i18n/rules/text-direction.md +199 -0
  131. package/templates/base/skills/mobile/flutter/monitoring/SKILL.md +81 -0
  132. package/templates/base/skills/mobile/flutter/monitoring/references/monitoring-architecture.md +269 -0
  133. package/templates/base/skills/mobile/flutter/monitoring/rules/analytics.md +227 -0
  134. package/templates/base/skills/mobile/flutter/monitoring/rules/crashlytics-setup.md +195 -0
  135. package/templates/base/skills/mobile/flutter/monitoring/rules/logging.md +258 -0
  136. package/templates/base/skills/mobile/flutter/monitoring/rules/performance-monitoring.md +248 -0
  137. package/templates/base/skills/mobile/flutter/monitoring/rules/sentry-integration.md +249 -0
  138. package/templates/base/skills/mobile/flutter/networking/SKILL.md +88 -0
  139. package/templates/base/skills/mobile/flutter/networking/references/api-client-architecture.md +305 -0
  140. package/templates/base/skills/mobile/flutter/networking/rules/caching.md +212 -0
  141. package/templates/base/skills/mobile/flutter/networking/rules/connectivity.md +213 -0
  142. package/templates/base/skills/mobile/flutter/networking/rules/dio-setup.md +159 -0
  143. package/templates/base/skills/mobile/flutter/networking/rules/error-handling.md +209 -0
  144. package/templates/base/skills/mobile/flutter/networking/rules/interceptors.md +205 -0
  145. package/templates/base/skills/mobile/flutter/networking/rules/retrofit-patterns.md +194 -0
  146. package/templates/base/skills/mobile/flutter/push-notifications/SKILL.md +87 -0
  147. package/templates/base/skills/mobile/flutter/push-notifications/references/notification-architecture.md +340 -0
  148. package/templates/base/skills/mobile/flutter/push-notifications/references/platform-setup.md +286 -0
  149. package/templates/base/skills/mobile/flutter/push-notifications/rules/background-processing.md +308 -0
  150. package/templates/base/skills/mobile/flutter/push-notifications/rules/deep-linking.md +217 -0
  151. package/templates/base/skills/mobile/flutter/push-notifications/rules/fcm-setup.md +164 -0
  152. package/templates/base/skills/mobile/flutter/push-notifications/rules/local-notifications.md +262 -0
  153. package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-handling.md +210 -0
  154. package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-permissions.md +246 -0
  155. package/templates/base/skills/mobile/flutter/push-notifications/rules/rich-notifications.md +320 -0
  156. package/templates/base/skills/mobile/flutter/references/freezed-patterns.md +162 -0
  157. package/templates/base/skills/mobile/flutter/references/project-structure.md +170 -0
  158. package/templates/base/skills/mobile/flutter/rules/animations.md +112 -0
  159. package/templates/base/skills/mobile/flutter/rules/architecture.md +121 -0
  160. package/templates/base/skills/mobile/flutter/rules/navigation-routing.md +117 -0
  161. package/templates/base/skills/mobile/flutter/rules/performance.md +112 -0
  162. package/templates/base/skills/mobile/flutter/rules/platform-adaptive.md +126 -0
  163. package/templates/base/skills/mobile/flutter/rules/state-management.md +110 -0
  164. package/templates/base/skills/mobile/flutter/rules/testing.md +131 -0
  165. package/templates/base/skills/mobile/flutter/rules/widget-conventions.md +122 -0
  166. package/templates/base/skills/mobile/flutter/security/SKILL.md +86 -0
  167. package/templates/base/skills/mobile/flutter/security/references/mobile-security-checklist.md +168 -0
  168. package/templates/base/skills/mobile/flutter/security/rules/api-key-protection.md +206 -0
  169. package/templates/base/skills/mobile/flutter/security/rules/authentication.md +248 -0
  170. package/templates/base/skills/mobile/flutter/security/rules/data-protection.md +271 -0
  171. package/templates/base/skills/mobile/flutter/security/rules/obfuscation.md +213 -0
  172. package/templates/base/skills/mobile/flutter/security/rules/secure-storage.md +171 -0
  173. package/templates/base/skills/mobile/flutter/security/rules/ssl-pinning.md +197 -0
  174. package/templates/base/skills/stability-verification/SKILL.md +64 -0
  175. package/templates/base/skills/stability-verification/references/release-checklist.md +34 -0
  176. package/templates/base/skills/stability-verification/references/security-fix-patterns.md +112 -0
  177. package/templates/base/skills/stability-verification/rules/verification-layers.md +67 -0
  178. package/templates/base/skills/stability-verification/rules/verification-workflow.md +69 -0
  179. package/templates/base/skills/stability-verification/scripts/verify.sh +294 -0
  180. package/templates/platforms/claude-code/CLAUDE.md.template +25 -0
  181. package/templates/platforms/claude-code/rules/build-gate.md +28 -0
  182. package/templates/platforms/claude-code/rules/completion-verification.md +30 -0
  183. package/templates/platforms/claude-code/rules/context-monitor.md +23 -0
  184. package/templates/platforms/claude-code/rules/plan-review.md +45 -0
  185. package/templates/platforms/claude-code/rules/quality-guards.md +43 -0
  186. package/templates/platforms/claude-code/rules/session-notes.md +18 -0
  187. package/templates/platforms/claude-code/rules/skill-suggest.md +27 -0
  188. package/templates/platforms/claude-code/scripts/build-gate.sh +73 -0
  189. package/templates/platforms/claude-code/scripts/completion-guard.sh +93 -0
  190. package/templates/platforms/claude-code/scripts/phase-guard.sh +79 -0
  191. package/templates/platforms/claude-code/scripts/safe-guard.sh +83 -0
  192. package/templates/platforms/claude-code/scripts/skill-rules.json +85 -0
  193. package/templates/platforms/claude-code/scripts/skill-suggest.sh +105 -0
  194. package/templates/platforms/claude-code/settings.json +111 -3
  195. package/templates/project-types/mobile-app/config.yaml +123 -0
  196. package/templates/project-types/mobile-app/process/workflow.xml +191 -0
@@ -0,0 +1,246 @@
1
+ ---
2
+ title: Notification Permissions
3
+ impact: HIGH
4
+ impactDescription: "잘못된 타이밍 → 거부율 40-60%, 적절한 타이밍 → 수락률 70%+"
5
+ tags: permission, ios, android, provisional, runtime
6
+ ---
7
+
8
+ ## Notification Permissions
9
+
10
+ **Impact: HIGH (잘못된 타이밍 → 거부율 40-60%, 적절한 타이밍 → 수락률 70%+)**
11
+
12
+ 알림 권한 요청 타이밍, 프리프롬프트 전략, 플랫폼별 권한 처리.
13
+
14
+ ### 권한 요청 타이밍
15
+
16
+ **Incorrect (앱 시작 즉시 요청):**
17
+ ```dart
18
+ // main.dart 또는 스플래시에서 바로 요청
19
+ // → 사용자가 앱 가치를 모르는 상태에서 거부 → 복구 어려움
20
+ Future<void> main() async {
21
+ WidgetsFlutterBinding.ensureInitialized();
22
+ await Firebase.initializeApp();
23
+ // 즉시 권한 요청 → 거부율 높음
24
+ await FirebaseMessaging.instance.requestPermission();
25
+ runApp(const MyApp());
26
+ }
27
+ ```
28
+
29
+ **Correct (가치 인지 후 맥락적 요청):**
30
+ ```dart
31
+ /// 권한 요청 서비스
32
+ class NotificationPermissionService {
33
+ final FirebaseMessaging _messaging = FirebaseMessaging.instance;
34
+
35
+ /// 현재 권한 상태 확인
36
+ Future<AuthorizationStatus> checkPermission() async {
37
+ final settings = await _messaging.getNotificationSettings();
38
+ return settings.authorizationStatus;
39
+ }
40
+
41
+ /// iOS: 임시(provisional) 권한으로 조용히 시작
42
+ Future<AuthorizationStatus> requestProvisional() async {
43
+ final settings = await _messaging.requestPermission(
44
+ provisional: true, // iOS만: 알림 센터에 조용히 전달
45
+ alert: true,
46
+ badge: true,
47
+ sound: true,
48
+ );
49
+ return settings.authorizationStatus;
50
+ }
51
+
52
+ /// 정식 권한 요청 (프리프롬프트 후)
53
+ Future<AuthorizationStatus> requestFull() async {
54
+ final settings = await _messaging.requestPermission(
55
+ alert: true,
56
+ badge: true,
57
+ sound: true,
58
+ announcement: false,
59
+ carPlay: false,
60
+ criticalAlert: false, // 긴급 알림 (별도 Apple 승인 필요)
61
+ provisional: false,
62
+ );
63
+ return settings.authorizationStatus;
64
+ }
65
+
66
+ /// Android 13+ 런타임 권한 요청
67
+ Future<bool> requestAndroidPermission() async {
68
+ if (Platform.isAndroid) {
69
+ final androidPlugin = FlutterLocalNotificationsPlugin()
70
+ .resolvePlatformSpecificImplementation<
71
+ AndroidFlutterLocalNotificationsPlugin>();
72
+ // Android 13 (API 33)+ 에서만 필요
73
+ final granted = await androidPlugin?.requestNotificationsPermission();
74
+ return granted ?? true; // Android 12 이하는 자동 허용
75
+ }
76
+ return true;
77
+ }
78
+ }
79
+ ```
80
+
81
+ ### 프리프롬프트 패턴 (권한 요청 전 설명)
82
+
83
+ ```dart
84
+ /// 매치 참가 후 알림 권한 요청 (맥락적)
85
+ class MatchJoinedPermissionPrompt extends ConsumerWidget {
86
+ const MatchJoinedPermissionPrompt({super.key});
87
+
88
+ @override
89
+ Widget build(BuildContext context, WidgetRef ref) {
90
+ final permissionStatus = ref.watch(notificationPermissionProvider);
91
+
92
+ // 이미 허용됨 → 표시 안 함
93
+ if (permissionStatus == AuthorizationStatus.authorized) {
94
+ return const SizedBox.shrink();
95
+ }
96
+
97
+ // 이미 명시적 거부 → 표시 안 함 (설정 유도는 별도)
98
+ if (permissionStatus == AuthorizationStatus.denied) {
99
+ return const SizedBox.shrink();
100
+ }
101
+
102
+ return Card(
103
+ margin: const EdgeInsets.all(16),
104
+ child: Padding(
105
+ padding: const EdgeInsets.all(16),
106
+ child: Column(
107
+ mainAxisSize: MainAxisSize.min,
108
+ children: [
109
+ const Icon(Icons.notifications_active, size: 48),
110
+ const SizedBox(height: 12),
111
+ const Text(
112
+ 'Get match updates',
113
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
114
+ ),
115
+ const SizedBox(height: 8),
116
+ const Text(
117
+ 'We\'ll notify you when your match time approaches '
118
+ 'and when teammates send messages.',
119
+ textAlign: TextAlign.center,
120
+ ),
121
+ const SizedBox(height: 16),
122
+ Row(
123
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
124
+ children: [
125
+ TextButton(
126
+ onPressed: () {
127
+ // "나중에" — 다음 적절한 시점에 다시 표시
128
+ ref.read(permissionDismissCountProvider.notifier)
129
+ .increment();
130
+ },
131
+ child: const Text('Maybe Later'),
132
+ ),
133
+ FilledButton(
134
+ onPressed: () async {
135
+ final service = ref.read(
136
+ notificationPermissionServiceProvider,
137
+ );
138
+ await service.requestFull();
139
+ },
140
+ child: const Text('Enable Notifications'),
141
+ ),
142
+ ],
143
+ ),
144
+ ],
145
+ ),
146
+ ),
147
+ );
148
+ }
149
+ }
150
+ ```
151
+
152
+ ### 권한 거부 → 설정 유도
153
+
154
+ ```dart
155
+ /// 알림이 필요한 기능 사용 시 설정 유도
156
+ Future<void> handleNotificationRequired(BuildContext context) async {
157
+ final status = await FirebaseMessaging.instance.getNotificationSettings();
158
+
159
+ if (status.authorizationStatus == AuthorizationStatus.denied) {
160
+ if (!context.mounted) return;
161
+
162
+ final goToSettings = await showDialog<bool>(
163
+ context: context,
164
+ builder: (context) => AlertDialog(
165
+ title: const Text('Notifications Disabled'),
166
+ content: const Text(
167
+ 'To receive match alerts, please enable notifications '
168
+ 'in your device settings.',
169
+ ),
170
+ actions: [
171
+ TextButton(
172
+ onPressed: () => Navigator.pop(context, false),
173
+ child: const Text('Cancel'),
174
+ ),
175
+ FilledButton(
176
+ onPressed: () => Navigator.pop(context, true),
177
+ child: const Text('Open Settings'),
178
+ ),
179
+ ],
180
+ ),
181
+ );
182
+
183
+ if (goToSettings == true) {
184
+ await AppSettings.openAppSettings(type: AppSettingsType.notification);
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### 권한 상태 Provider
191
+
192
+ ```dart
193
+ /// 알림 권한 상태 추적
194
+ final notificationPermissionProvider =
195
+ StreamProvider<AuthorizationStatus>((ref) async* {
196
+ // 초기 상태
197
+ final settings =
198
+ await FirebaseMessaging.instance.getNotificationSettings();
199
+ yield settings.authorizationStatus;
200
+
201
+ // 앱 라이프사이클 변경 시 재확인 (설정에서 돌아온 후)
202
+ // AppLifecycleState.resumed 시 재확인 로직은 별도 리스너에서
203
+ });
204
+
205
+ /// 프리프롬프트 dismiss 횟수 (너무 자주 표시 방지)
206
+ final permissionDismissCountProvider =
207
+ NotifierProvider<PermissionDismissNotifier, int>(
208
+ PermissionDismissNotifier.new,
209
+ );
210
+
211
+ class PermissionDismissNotifier extends Notifier<int> {
212
+ @override
213
+ int build() {
214
+ // SharedPreferences에서 로드
215
+ return 0;
216
+ }
217
+
218
+ void increment() {
219
+ state++;
220
+ // SharedPreferences에 저장
221
+ }
222
+
223
+ /// 3회 이상 dismiss → 더 이상 표시 안 함
224
+ bool get shouldShow => state < 3;
225
+ }
226
+ ```
227
+
228
+ ### 플랫폼별 차이
229
+
230
+ | 항목 | iOS | Android |
231
+ |------|-----|---------|
232
+ | 권한 요청 | 1번만 시스템 다이얼로그 | Android 13+: 런타임 권한 |
233
+ | 임시 알림 | `provisional: true` 지원 | 해당 없음 |
234
+ | 거부 후 복구 | 설정 앱에서만 가능 | 설정 앱에서만 가능 |
235
+ | 기본 상태 | `notDetermined` | Android 12 이하: 자동 허용 |
236
+ | 채널별 제어 | 없음 (전체 on/off) | 채널별 개별 제어 가능 |
237
+
238
+ ### 규칙
239
+
240
+ - 앱 첫 실행 시 즉시 권한 요청 금지 — 가치 인지 후 맥락적 요청
241
+ - iOS: `provisional` 먼저 → 사용자가 알림 가치 확인 → 정식 요청
242
+ - Android 13+: `POST_NOTIFICATIONS` 런타임 권한 처리 필수
243
+ - 프리프롬프트 → 시스템 다이얼로그 전에 앱 내 설명 UI 표시
244
+ - 거부 시 → 설정 앱 유도 (`app_settings` 패키지)
245
+ - dismiss 횟수 추적 → 3회 이상 거부 시 더 이상 표시하지 않음
246
+ - 권한 상태 → Provider로 추적, 앱 resume 시 재확인
@@ -0,0 +1,320 @@
1
+ ---
2
+ title: Rich Notifications
3
+ impact: HIGH
4
+ impactDescription: "리치 알림 → 탭률 20-30% 향상, 이미지+액션 → 사용자 참여 증가"
5
+ tags: rich-notification, image, action-button, grouped, android, ios
6
+ ---
7
+
8
+ ## Rich Notifications
9
+
10
+ **Impact: HIGH (리치 알림 → 탭률 20-30% 향상, 이미지+액션 → 사용자 참여 증가)**
11
+
12
+ 이미지 첨부, 액션 버튼, 그룹 알림, 커스텀 사운드. 플랫폼별 구현 차이.
13
+
14
+ ### 이미지 알림
15
+
16
+ **Incorrect (이미지 URL 직접 전달 → 플랫폼별 미처리):**
17
+ ```dart
18
+ await plugin.show(
19
+ id, title, body,
20
+ NotificationDetails(
21
+ android: AndroidNotificationDetails('ch', 'Ch',
22
+ // 이미지 없음 → 텍스트만 표시
23
+ ),
24
+ ),
25
+ );
26
+ ```
27
+
28
+ **Correct (이미지 다운로드 + 플랫폼별 스타일):**
29
+ ```dart
30
+ import 'package:http/http.dart' as http;
31
+ import 'dart:io';
32
+ import 'dart:typed_data';
33
+
34
+ class RichNotificationService {
35
+ final FlutterLocalNotificationsPlugin _plugin;
36
+
37
+ RichNotificationService(this._plugin);
38
+
39
+ /// 이미지 포함 알림 표시
40
+ Future<void> showImageNotification({
41
+ required int id,
42
+ required String title,
43
+ required String body,
44
+ required String imageUrl,
45
+ String? payload,
46
+ String channelId = 'matches',
47
+ }) async {
48
+ // 이미지 다운로드
49
+ final BigPictureStyleInformation? androidStyle =
50
+ await _getAndroidBigPicture(imageUrl, title, body);
51
+ final DarwinNotificationDetails? iosDetails =
52
+ await _getIosAttachment(imageUrl);
53
+
54
+ await _plugin.show(
55
+ id,
56
+ title,
57
+ body,
58
+ NotificationDetails(
59
+ android: AndroidNotificationDetails(
60
+ channelId,
61
+ _channelName(channelId),
62
+ importance: Importance.high,
63
+ priority: Priority.high,
64
+ styleInformation: androidStyle ??
65
+ BigTextStyleInformation(body), // 이미지 실패 시 폴백
66
+ largeIcon: androidStyle != null
67
+ ? FilePathAndroidBitmap(
68
+ await _downloadToFile(imageUrl, 'large_icon'))
69
+ : null,
70
+ ),
71
+ iOS: iosDetails ?? const DarwinNotificationDetails(),
72
+ ),
73
+ payload: payload,
74
+ );
75
+ }
76
+
77
+ Future<BigPictureStyleInformation?> _getAndroidBigPicture(
78
+ String imageUrl,
79
+ String title,
80
+ String body,
81
+ ) async {
82
+ try {
83
+ final filePath = await _downloadToFile(imageUrl, 'big_picture');
84
+ return BigPictureStyleInformation(
85
+ FilePathAndroidBitmap(filePath),
86
+ contentTitle: title,
87
+ summaryText: body,
88
+ hideExpandedLargeIcon: true,
89
+ );
90
+ } catch (e) {
91
+ return null; // 이미지 실패 → 텍스트 폴백
92
+ }
93
+ }
94
+
95
+ Future<DarwinNotificationDetails?> _getIosAttachment(
96
+ String imageUrl) async {
97
+ try {
98
+ final filePath = await _downloadToFile(imageUrl, 'ios_attachment');
99
+ return DarwinNotificationDetails(
100
+ attachments: [DarwinNotificationAttachment(filePath)],
101
+ );
102
+ } catch (e) {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ Future<String> _downloadToFile(String url, String prefix) async {
108
+ final response = await http.get(Uri.parse(url));
109
+ final dir = await getTemporaryDirectory();
110
+ final file = File('${dir.path}/${prefix}_${url.hashCode}.jpg');
111
+ await file.writeAsBytes(response.bodyBytes);
112
+ return file.path;
113
+ }
114
+
115
+ String _channelName(String id) => switch (id) {
116
+ 'matches' => 'Match Alerts',
117
+ 'chat' => 'Chat Messages',
118
+ _ => 'Notifications',
119
+ };
120
+ }
121
+ ```
122
+
123
+ ### 액션 버튼
124
+
125
+ ```dart
126
+ // === Android 액션 버튼 ===
127
+ Future<void> showNotificationWithActions({
128
+ required int id,
129
+ required String title,
130
+ required String body,
131
+ required String matchId,
132
+ }) async {
133
+ await _plugin.show(
134
+ id,
135
+ title,
136
+ body,
137
+ NotificationDetails(
138
+ android: AndroidNotificationDetails(
139
+ 'matches',
140
+ 'Match Alerts',
141
+ importance: Importance.high,
142
+ actions: [
143
+ const AndroidNotificationAction(
144
+ 'accept', // actionId
145
+ 'Accept', // 버튼 텍스트
146
+ showsUserInterface: true, // 앱을 포그라운드로
147
+ ),
148
+ const AndroidNotificationAction(
149
+ 'decline',
150
+ 'Decline',
151
+ cancelNotification: true, // 탭 시 알림 제거
152
+ ),
153
+ const AndroidNotificationAction(
154
+ 'reply',
155
+ 'Reply',
156
+ inputs: [
157
+ AndroidNotificationActionInput(
158
+ label: 'Type a message...',
159
+ ),
160
+ ],
161
+ ),
162
+ ],
163
+ ),
164
+ // iOS: DarwinNotificationCategory에서 액션 정의 (초기화 시 등록)
165
+ iOS: const DarwinNotificationDetails(
166
+ categoryIdentifier: 'match_invite', // 초기화 시 등록한 카테고리
167
+ ),
168
+ ),
169
+ payload: jsonEncode({'type': 'match', 'id': matchId}),
170
+ );
171
+ }
172
+
173
+ // 액션 버튼 탭 핸들러
174
+ void _onNotificationTap(NotificationResponse response) {
175
+ final actionId = response.actionId; // 'accept', 'decline', 'reply'
176
+
177
+ switch (actionId) {
178
+ case 'accept':
179
+ final payload = _parsePayload(response.payload);
180
+ _matchService.acceptInvite(payload['id']!);
181
+ _router.push('/match/${payload['id']}');
182
+ case 'decline':
183
+ final payload = _parsePayload(response.payload);
184
+ _matchService.declineInvite(payload['id']!);
185
+ case 'reply':
186
+ final input = response.input; // 사용자 입력 텍스트
187
+ if (input != null) {
188
+ final payload = _parsePayload(response.payload);
189
+ _chatService.sendMessage(payload['id']!, input);
190
+ }
191
+ default:
192
+ // 일반 탭 (액션 버튼이 아닌 알림 본문 탭)
193
+ _navigateFromPayload(response.payload);
194
+ }
195
+ }
196
+ ```
197
+
198
+ ### 그룹 알림 (Android)
199
+
200
+ ```dart
201
+ /// Android 알림 그룹핑
202
+ Future<void> showGroupedNotifications({
203
+ required List<ChatMessage> messages,
204
+ required String chatId,
205
+ required String chatName,
206
+ }) async {
207
+ const groupKey = 'chat_messages';
208
+
209
+ // 개별 알림
210
+ for (int i = 0; i < messages.length; i++) {
211
+ final msg = messages[i];
212
+ await _plugin.show(
213
+ msg.hashCode,
214
+ chatName,
215
+ '${msg.sender}: ${msg.text}',
216
+ NotificationDetails(
217
+ android: AndroidNotificationDetails(
218
+ 'chat',
219
+ 'Chat Messages',
220
+ groupKey: groupKey,
221
+ // InboxStyle로 여러 줄 표시
222
+ styleInformation: InboxStyleInformation(
223
+ [msg.text],
224
+ contentTitle: chatName,
225
+ ),
226
+ ),
227
+ ),
228
+ payload: jsonEncode({'type': 'chat', 'id': chatId}),
229
+ );
230
+ }
231
+
232
+ // 요약 알림 (그룹 헤더)
233
+ await _plugin.show(
234
+ 0, // 고정 ID — 요약 알림은 1개만
235
+ chatName,
236
+ '${messages.length} new messages',
237
+ NotificationDetails(
238
+ android: AndroidNotificationDetails(
239
+ 'chat',
240
+ 'Chat Messages',
241
+ groupKey: groupKey,
242
+ setAsGroupSummary: true, // 이것이 그룹 요약
243
+ styleInformation: InboxStyleInformation(
244
+ messages.map((m) => '${m.sender}: ${m.text}').toList(),
245
+ contentTitle: '$chatName (${messages.length})',
246
+ summaryText: '${messages.length} new messages',
247
+ ),
248
+ ),
249
+ ),
250
+ );
251
+ }
252
+ ```
253
+
254
+ ### 커스텀 사운드
255
+
256
+ ```dart
257
+ // Android: res/raw/custom_sound.mp3 (확장자 제외)
258
+ // iOS: Runner/custom_sound.aiff (또는 .wav, .caf)
259
+
260
+ const androidDetails = AndroidNotificationDetails(
261
+ 'matches',
262
+ 'Match Alerts',
263
+ sound: RawResourceAndroidNotificationSound('custom_sound'),
264
+ playSound: true,
265
+ );
266
+
267
+ const iosDetails = DarwinNotificationDetails(
268
+ sound: 'custom_sound.aiff',
269
+ presentSound: true,
270
+ );
271
+ ```
272
+
273
+ ### 서버 발송 시 리치 알림 (FCM HTTP v1)
274
+
275
+ ```json
276
+ {
277
+ "message": {
278
+ "token": "device_token",
279
+ "notification": {
280
+ "title": "New Match Invite",
281
+ "body": "Join the Tennis match at 3 PM",
282
+ "image": "https://example.com/match_banner.jpg"
283
+ },
284
+ "data": {
285
+ "type": "match",
286
+ "id": "match_123",
287
+ "action": "invite"
288
+ },
289
+ "android": {
290
+ "notification": {
291
+ "channel_id": "matches",
292
+ "image": "https://example.com/match_banner.jpg",
293
+ "click_action": "FLUTTER_NOTIFICATION_CLICK"
294
+ }
295
+ },
296
+ "apns": {
297
+ "payload": {
298
+ "aps": {
299
+ "mutable-content": 1,
300
+ "sound": "default"
301
+ }
302
+ },
303
+ "fcm_options": {
304
+ "image": "https://example.com/match_banner.jpg"
305
+ }
306
+ }
307
+ }
308
+ }
309
+ ```
310
+
311
+ ### 규칙
312
+
313
+ - 이미지 → 다운로드 후 로컬 파일 경로 전달 (URL 직접 전달 X)
314
+ - 이미지 실패 → `BigTextStyleInformation` 텍스트 폴백 필수
315
+ - 액션 버튼 → Android: `AndroidNotificationAction`, iOS: `DarwinNotificationCategory`
316
+ - iOS 카테고리 → 초기화 시 등록 (`DarwinInitializationSettings.notificationCategories`)
317
+ - 그룹 알림 → `groupKey` 동일 + 요약 알림 `setAsGroupSummary: true`
318
+ - 커스텀 사운드 → Android: `res/raw/`, iOS: Runner 번들에 포함
319
+ - FCM 이미지 → `notification.image` (서버에서), `mutable-content: 1` (iOS 필수)
320
+ - 임시 파일 → `getTemporaryDirectory()` 사용, 주기적 정리 고려