timsquad 3.3.0 → 3.4.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.
- package/README.ko.md +288 -0
- package/README.md +158 -151
- package/dist/commands/compile.d.ts +3 -0
- package/dist/commands/compile.d.ts.map +1 -0
- package/dist/commands/compile.js +170 -0
- package/dist/commands/compile.js.map +1 -0
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +94 -5
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +12 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/skills.d.ts +12 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +231 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/upgrade.js +5 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/daemon/entry.js +3 -3
- package/dist/daemon/entry.js.map +1 -1
- package/dist/daemon/index.d.ts +3 -2
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +137 -45
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/meta-cache.d.ts +1 -0
- package/dist/daemon/meta-cache.d.ts.map +1 -1
- package/dist/daemon/meta-cache.js +9 -0
- package/dist/daemon/meta-cache.js.map +1 -1
- package/dist/daemon/session-state.d.ts +19 -0
- package/dist/daemon/session-state.d.ts.map +1 -0
- package/dist/daemon/session-state.js +132 -0
- package/dist/daemon/session-state.js.map +1 -0
- package/dist/daemon/shutdown.d.ts.map +1 -1
- package/dist/daemon/shutdown.js +7 -1
- package/dist/daemon/shutdown.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/compile-rules.d.ts +66 -0
- package/dist/lib/compile-rules.d.ts.map +1 -0
- package/dist/lib/compile-rules.js +114 -0
- package/dist/lib/compile-rules.js.map +1 -0
- package/dist/lib/compiler.d.ts +105 -0
- package/dist/lib/compiler.d.ts.map +1 -0
- package/dist/lib/compiler.js +368 -0
- package/dist/lib/compiler.js.map +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +8 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/template.d.ts.map +1 -1
- package/dist/lib/template.js +6 -0
- package/dist/lib/template.js.map +1 -1
- package/dist/types/config.d.ts +1 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +12 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/project.d.ts +1 -1
- package/dist/types/project.d.ts.map +1 -1
- package/dist/types/project.js +2 -0
- package/dist/types/project.js.map +1 -1
- package/package.json +1 -1
- package/templates/base/agents/overlays/domain/mobile/_common.md +13 -0
- package/templates/base/skills/controller/SKILL.md +111 -0
- package/templates/base/skills/controller/references/README.md +35 -0
- package/templates/base/skills/controller/rules/README.md +18 -0
- package/templates/base/skills/mobile/dart/SKILL.md +69 -0
- package/templates/base/skills/mobile/dart/rules/async-patterns.md +112 -0
- package/templates/base/skills/mobile/dart/rules/code-style.md +96 -0
- package/templates/base/skills/mobile/dart/rules/null-safety.md +84 -0
- package/templates/base/skills/mobile/dart/rules/type-system.md +111 -0
- package/templates/base/skills/mobile/flutter/SKILL.md +89 -0
- package/templates/base/skills/mobile/flutter/ci-cd/SKILL.md +82 -0
- package/templates/base/skills/mobile/flutter/ci-cd/references/ci-cd-pipeline.md +314 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/code-signing.md +106 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/codemagic-setup.md +116 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/fastlane-setup.md +105 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/github-actions.md +112 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/store-deployment.md +106 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/versioning.md +107 -0
- package/templates/base/skills/mobile/flutter/i18n/SKILL.md +78 -0
- package/templates/base/skills/mobile/flutter/i18n/references/i18n-architecture.md +225 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/arb-files.md +182 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/locale-switching.md +226 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/localization-setup.md +137 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/plural-gender.md +159 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/text-direction.md +199 -0
- package/templates/base/skills/mobile/flutter/monitoring/SKILL.md +81 -0
- package/templates/base/skills/mobile/flutter/monitoring/references/monitoring-architecture.md +269 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/analytics.md +227 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/crashlytics-setup.md +195 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/logging.md +258 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/performance-monitoring.md +248 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/sentry-integration.md +249 -0
- package/templates/base/skills/mobile/flutter/networking/SKILL.md +88 -0
- package/templates/base/skills/mobile/flutter/networking/references/api-client-architecture.md +305 -0
- package/templates/base/skills/mobile/flutter/networking/rules/caching.md +212 -0
- package/templates/base/skills/mobile/flutter/networking/rules/connectivity.md +213 -0
- package/templates/base/skills/mobile/flutter/networking/rules/dio-setup.md +159 -0
- package/templates/base/skills/mobile/flutter/networking/rules/error-handling.md +209 -0
- package/templates/base/skills/mobile/flutter/networking/rules/interceptors.md +205 -0
- package/templates/base/skills/mobile/flutter/networking/rules/retrofit-patterns.md +194 -0
- package/templates/base/skills/mobile/flutter/push-notifications/SKILL.md +87 -0
- package/templates/base/skills/mobile/flutter/push-notifications/references/notification-architecture.md +340 -0
- package/templates/base/skills/mobile/flutter/push-notifications/references/platform-setup.md +286 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/background-processing.md +308 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/deep-linking.md +217 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/fcm-setup.md +164 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/local-notifications.md +262 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-handling.md +210 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-permissions.md +246 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/rich-notifications.md +320 -0
- package/templates/base/skills/mobile/flutter/references/freezed-patterns.md +162 -0
- package/templates/base/skills/mobile/flutter/references/project-structure.md +170 -0
- package/templates/base/skills/mobile/flutter/rules/animations.md +112 -0
- package/templates/base/skills/mobile/flutter/rules/architecture.md +121 -0
- package/templates/base/skills/mobile/flutter/rules/navigation-routing.md +117 -0
- package/templates/base/skills/mobile/flutter/rules/performance.md +112 -0
- package/templates/base/skills/mobile/flutter/rules/platform-adaptive.md +126 -0
- package/templates/base/skills/mobile/flutter/rules/state-management.md +110 -0
- package/templates/base/skills/mobile/flutter/rules/testing.md +131 -0
- package/templates/base/skills/mobile/flutter/rules/widget-conventions.md +122 -0
- package/templates/base/skills/mobile/flutter/security/SKILL.md +86 -0
- package/templates/base/skills/mobile/flutter/security/references/mobile-security-checklist.md +168 -0
- package/templates/base/skills/mobile/flutter/security/rules/api-key-protection.md +206 -0
- package/templates/base/skills/mobile/flutter/security/rules/authentication.md +248 -0
- package/templates/base/skills/mobile/flutter/security/rules/data-protection.md +271 -0
- package/templates/base/skills/mobile/flutter/security/rules/obfuscation.md +213 -0
- package/templates/base/skills/mobile/flutter/security/rules/secure-storage.md +171 -0
- package/templates/base/skills/mobile/flutter/security/rules/ssl-pinning.md +197 -0
- package/templates/platforms/claude-code/CLAUDE.md.template +25 -0
- package/templates/platforms/claude-code/scripts/completion-guard.sh +57 -0
- package/templates/platforms/claude-code/scripts/phase-guard.sh +79 -0
- package/templates/platforms/claude-code/settings.json +75 -3
- package/templates/project-types/mobile-app/config.yaml +123 -0
- package/templates/project-types/mobile-app/process/workflow.xml +191 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Performance Optimization
|
|
3
|
+
impact: HIGH
|
|
4
|
+
tags: performance, rebuild, lazy-loading, impeller
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Performance Optimization
|
|
8
|
+
|
|
9
|
+
Flutter 렌더링 파이프라인 최적화. 불필요한 리빌드 차단, lazy 로딩, 메모리 관리.
|
|
10
|
+
|
|
11
|
+
### const 위젯으로 리빌드 차단
|
|
12
|
+
|
|
13
|
+
**Incorrect:**
|
|
14
|
+
```dart
|
|
15
|
+
@override
|
|
16
|
+
Widget build(BuildContext context) {
|
|
17
|
+
return Column(
|
|
18
|
+
children: [
|
|
19
|
+
// 매 빌드마다 재생성 (부모 상태 변경 시)
|
|
20
|
+
Header(),
|
|
21
|
+
Divider(),
|
|
22
|
+
Text('Fixed label'),
|
|
23
|
+
// 실제로 변경되는 부분
|
|
24
|
+
Text(counter.toString()),
|
|
25
|
+
],
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Correct:**
|
|
31
|
+
```dart
|
|
32
|
+
@override
|
|
33
|
+
Widget build(BuildContext context) {
|
|
34
|
+
return Column(
|
|
35
|
+
children: [
|
|
36
|
+
const Header(), // 리빌드 스킵
|
|
37
|
+
const Divider(), // 리빌드 스킵
|
|
38
|
+
const Text('Fixed label'), // 리빌드 스킵
|
|
39
|
+
Text(counter.toString()), // 이것만 리빌드
|
|
40
|
+
],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 리스트 최적화
|
|
46
|
+
|
|
47
|
+
**Incorrect:**
|
|
48
|
+
```dart
|
|
49
|
+
// 모든 아이템을 한번에 빌드 → 메모리 + 프레임 드롭
|
|
50
|
+
ListView(
|
|
51
|
+
children: matches.map((m) => MatchCard(match: m)).toList(),
|
|
52
|
+
);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Correct:**
|
|
56
|
+
```dart
|
|
57
|
+
// lazy 빌드 → 화면에 보이는 것만 생성
|
|
58
|
+
ListView.builder(
|
|
59
|
+
itemCount: matches.length,
|
|
60
|
+
itemBuilder: (context, index) => MatchCard(
|
|
61
|
+
key: ValueKey(matches[index].id),
|
|
62
|
+
match: matches[index],
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 무한 스크롤
|
|
67
|
+
ListView.builder(
|
|
68
|
+
itemCount: matches.length + (hasMore ? 1 : 0),
|
|
69
|
+
itemBuilder: (context, index) {
|
|
70
|
+
if (index == matches.length) {
|
|
71
|
+
ref.read(matchListProvider.notifier).loadMore();
|
|
72
|
+
return const LoadingTile();
|
|
73
|
+
}
|
|
74
|
+
return MatchCard(match: matches[index]);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### RepaintBoundary
|
|
80
|
+
|
|
81
|
+
```dart
|
|
82
|
+
// 자주 업데이트되는 영역 격리
|
|
83
|
+
RepaintBoundary(
|
|
84
|
+
child: LiveScoreBoard(matchId: matchId), // 1초마다 갱신
|
|
85
|
+
),
|
|
86
|
+
// ScoreBoard 리페인트가 부모 트리에 전파되지 않음
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 이미지 최적화
|
|
90
|
+
|
|
91
|
+
```dart
|
|
92
|
+
// cached_network_image + 적절한 크기
|
|
93
|
+
CachedNetworkImage(
|
|
94
|
+
imageUrl: user.avatarUrl,
|
|
95
|
+
width: 48,
|
|
96
|
+
height: 48,
|
|
97
|
+
memCacheWidth: 96, // 2x for retina
|
|
98
|
+
memCacheHeight: 96,
|
|
99
|
+
placeholder: (_, __) => const CircleAvatar(child: Icon(Icons.person)),
|
|
100
|
+
errorWidget: (_, __, ___) => const CircleAvatar(child: Icon(Icons.error)),
|
|
101
|
+
);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 규칙
|
|
105
|
+
|
|
106
|
+
- `const` 가능한 위젯은 반드시 `const` (lint 강제)
|
|
107
|
+
- 10개 이상 아이템 → `ListView.builder` / `SliverList` (lazy)
|
|
108
|
+
- `RepaintBoundary` → 자주 갱신되는 서브트리에 적용
|
|
109
|
+
- 이미지: `memCacheWidth/Height` 설정 (원본 크기 로딩 방지)
|
|
110
|
+
- `setState` 범위 최소화 — 변경되는 위젯만 리빌드
|
|
111
|
+
- build() 안에서 `Future`/무거운 연산 금지 → Provider/Notifier에서 처리
|
|
112
|
+
- DevTools Performance 뷰로 jank 프레임 확인
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Platform Adaptive UI
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
tags: adaptive, responsive, material, cupertino
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Platform Adaptive UI
|
|
8
|
+
|
|
9
|
+
크로스플랫폼 적응형 UI. Material + Cupertino 자동 전환, 반응형 레이아웃.
|
|
10
|
+
|
|
11
|
+
### 적응형 위젯 패턴
|
|
12
|
+
|
|
13
|
+
```dart
|
|
14
|
+
/// 플랫폼에 따라 Material/Cupertino 자동 선택
|
|
15
|
+
class AdaptiveDialog {
|
|
16
|
+
static Future<bool?> show(
|
|
17
|
+
BuildContext context, {
|
|
18
|
+
required String title,
|
|
19
|
+
required String content,
|
|
20
|
+
}) {
|
|
21
|
+
if (Platform.isIOS || Platform.isMacOS) {
|
|
22
|
+
return showCupertinoDialog<bool>(
|
|
23
|
+
context: context,
|
|
24
|
+
builder: (_) => CupertinoAlertDialog(
|
|
25
|
+
title: Text(title),
|
|
26
|
+
content: Text(content),
|
|
27
|
+
actions: [
|
|
28
|
+
CupertinoDialogAction(
|
|
29
|
+
onPressed: () => Navigator.pop(context, false),
|
|
30
|
+
child: const Text('Cancel'),
|
|
31
|
+
),
|
|
32
|
+
CupertinoDialogAction(
|
|
33
|
+
isDefaultAction: true,
|
|
34
|
+
onPressed: () => Navigator.pop(context, true),
|
|
35
|
+
child: const Text('OK'),
|
|
36
|
+
),
|
|
37
|
+
],
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return showDialog<bool>(
|
|
42
|
+
context: context,
|
|
43
|
+
builder: (_) => AlertDialog(
|
|
44
|
+
title: Text(title),
|
|
45
|
+
content: Text(content),
|
|
46
|
+
actions: [
|
|
47
|
+
TextButton(
|
|
48
|
+
onPressed: () => Navigator.pop(context, false),
|
|
49
|
+
child: const Text('Cancel'),
|
|
50
|
+
),
|
|
51
|
+
FilledButton(
|
|
52
|
+
onPressed: () => Navigator.pop(context, true),
|
|
53
|
+
child: const Text('OK'),
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 반응형 레이아웃
|
|
63
|
+
|
|
64
|
+
```dart
|
|
65
|
+
class ResponsiveLayout extends StatelessWidget {
|
|
66
|
+
const ResponsiveLayout({
|
|
67
|
+
super.key,
|
|
68
|
+
required this.mobile,
|
|
69
|
+
this.tablet,
|
|
70
|
+
this.desktop,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
final Widget mobile;
|
|
74
|
+
final Widget? tablet;
|
|
75
|
+
final Widget? desktop;
|
|
76
|
+
|
|
77
|
+
static const mobileBreakpoint = 600.0;
|
|
78
|
+
static const tabletBreakpoint = 900.0;
|
|
79
|
+
|
|
80
|
+
@override
|
|
81
|
+
Widget build(BuildContext context) {
|
|
82
|
+
final width = MediaQuery.sizeOf(context).width;
|
|
83
|
+
|
|
84
|
+
if (width >= tabletBreakpoint && desktop != null) return desktop!;
|
|
85
|
+
if (width >= mobileBreakpoint && tablet != null) return tablet!;
|
|
86
|
+
return mobile;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 사용
|
|
91
|
+
ResponsiveLayout(
|
|
92
|
+
mobile: const MatchListMobile(),
|
|
93
|
+
tablet: const MatchListTablet(), // 2-column
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 플랫폼별 동작
|
|
98
|
+
|
|
99
|
+
```dart
|
|
100
|
+
// 스크롤 물리
|
|
101
|
+
ScrollConfiguration(
|
|
102
|
+
behavior: Platform.isIOS
|
|
103
|
+
? const CupertinoScrollBehavior() // 바운스
|
|
104
|
+
: const MaterialScrollBehavior(), // 글로우
|
|
105
|
+
child: ListView(...),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// 날짜 선택
|
|
109
|
+
Future<DateTime?> pickDate(BuildContext context) {
|
|
110
|
+
if (Platform.isIOS) {
|
|
111
|
+
return showCupertinoModalPopup<DateTime>(
|
|
112
|
+
context: context,
|
|
113
|
+
builder: (_) => CupertinoDatePicker(...),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return showDatePicker(context: context, ...);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 규칙
|
|
121
|
+
|
|
122
|
+
- 대화상자, 스위치, 날짜피커 → 플랫폼별 네이티브 위젯 사용
|
|
123
|
+
- 반응형 breakpoint: mobile < 600 < tablet < 900 < desktop
|
|
124
|
+
- `MediaQuery.sizeOf(context)` 사용 (`MediaQuery.of` 보다 효율적)
|
|
125
|
+
- 플랫폼 분기는 유틸 함수로 캡슐화 (위젯 안에 if/else 산재 금지)
|
|
126
|
+
- `Theme.of(context).platform` 으로 플랫폼 감지 (테스트 용이)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: State Management (Riverpod)
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
tags: riverpod, provider, notifier, state
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## State Management (Riverpod)
|
|
8
|
+
|
|
9
|
+
Riverpod 기반 상태 관리. 선언적 의존성, 자동 dispose, 타입 안전.
|
|
10
|
+
|
|
11
|
+
### Provider 타입 선택
|
|
12
|
+
|
|
13
|
+
| 용도 | Provider 타입 | 예시 |
|
|
14
|
+
|------|-------------|------|
|
|
15
|
+
| 동기 파생 값 | `Provider` | 필터링된 리스트, 계산값 |
|
|
16
|
+
| 비동기 단발 | `FutureProvider` | API 호출, 초기 데이터 로드 |
|
|
17
|
+
| 실시간 스트림 | `StreamProvider` | WebSocket, Firestore 스냅샷 |
|
|
18
|
+
| UI 상태 (CRUD) | `NotifierProvider` | 폼 상태, 필터, 페이지네이션 |
|
|
19
|
+
| 비동기 UI 상태 | `AsyncNotifierProvider` | 서버 데이터 + 뮤테이션 |
|
|
20
|
+
|
|
21
|
+
### ref 사용 구분
|
|
22
|
+
|
|
23
|
+
**Incorrect:**
|
|
24
|
+
```dart
|
|
25
|
+
class MatchListScreen extends ConsumerWidget {
|
|
26
|
+
@override
|
|
27
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
28
|
+
// build에서 ref.read → 변경 감지 안 됨
|
|
29
|
+
final matches = ref.read(matchListProvider);
|
|
30
|
+
|
|
31
|
+
return ElevatedButton(
|
|
32
|
+
// 이벤트에서 ref.watch → 불필요한 구독
|
|
33
|
+
onPressed: () => ref.watch(matchListProvider.notifier).refresh(),
|
|
34
|
+
child: Text('Refresh'),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Correct:**
|
|
41
|
+
```dart
|
|
42
|
+
class MatchListScreen extends ConsumerWidget {
|
|
43
|
+
@override
|
|
44
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
45
|
+
// build에서 ref.watch → 변경 시 리빌드
|
|
46
|
+
final matchesAsync = ref.watch(matchListProvider);
|
|
47
|
+
|
|
48
|
+
return matchesAsync.when(
|
|
49
|
+
data: (matches) => MatchListView(matches: matches),
|
|
50
|
+
loading: () => const MatchListSkeleton(),
|
|
51
|
+
error: (e, _) => ErrorView(error: e),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
void _onRefresh(WidgetRef ref) {
|
|
56
|
+
// 이벤트에서 ref.read → 1회성 접근
|
|
57
|
+
ref.read(matchListProvider.notifier).refresh();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Notifier 패턴
|
|
63
|
+
|
|
64
|
+
```dart
|
|
65
|
+
// Domain model
|
|
66
|
+
sealed class MatchListState {}
|
|
67
|
+
class MatchListInitial extends MatchListState {}
|
|
68
|
+
class MatchListLoaded extends MatchListState {
|
|
69
|
+
final List<Match> matches;
|
|
70
|
+
MatchListLoaded(this.matches);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Provider 선언
|
|
74
|
+
final matchListProvider =
|
|
75
|
+
AsyncNotifierProvider<MatchListNotifier, List<Match>>(
|
|
76
|
+
MatchListNotifier.new,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Notifier 구현
|
|
80
|
+
class MatchListNotifier extends AsyncNotifier<List<Match>> {
|
|
81
|
+
@override
|
|
82
|
+
Future<List<Match>> build() async {
|
|
83
|
+
// 의존성 주입: ref.watch로 다른 provider 구독
|
|
84
|
+
final sport = ref.watch(selectedSportProvider);
|
|
85
|
+
final repo = ref.watch(matchRepositoryProvider);
|
|
86
|
+
return repo.getMatches(sport: sport);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Future<void> refresh() async {
|
|
90
|
+
state = const AsyncLoading();
|
|
91
|
+
state = await AsyncValue.guard(() => build());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Future<void> joinMatch(String matchId) async {
|
|
95
|
+
final repo = ref.read(matchRepositoryProvider);
|
|
96
|
+
await repo.joinMatch(matchId);
|
|
97
|
+
ref.invalidateSelf(); // 데이터 새로고침
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 규칙
|
|
103
|
+
|
|
104
|
+
- `ref.watch` → build() 안에서만 (리액티브 구독)
|
|
105
|
+
- `ref.read` → 이벤트 핸들러, 콜백에서 (1회 접근)
|
|
106
|
+
- `ref.listen` → side effect (스낵바, 네비게이션)
|
|
107
|
+
- `autoDispose` 기본 사용 (화면 벗어나면 해제)
|
|
108
|
+
- Provider 파일 위치: `features/{name}/presentation/providers/`
|
|
109
|
+
- 전역 상태는 `core/providers/`에 배치
|
|
110
|
+
- `.when()` 패턴으로 loading/error/data 처리
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing Strategy
|
|
3
|
+
impact: HIGH
|
|
4
|
+
tags: testing, widget-test, integration, mocktail
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Testing Strategy
|
|
8
|
+
|
|
9
|
+
Flutter 3계층 테스트: Unit → Widget → Integration. mocktail + Patrol.
|
|
10
|
+
|
|
11
|
+
### 테스트 피라미드
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
╱╲
|
|
15
|
+
╱ ╲ Integration (Patrol)
|
|
16
|
+
╱ ╲ - 전체 플로우 (로그인→매칭→채팅)
|
|
17
|
+
╱──────╲ - 네이티브 상호작용 (권한, 알림)
|
|
18
|
+
╱ ╲
|
|
19
|
+
╱ Widget ╲ Widget Test
|
|
20
|
+
╱ ╲ - 개별 위젯 렌더링 + 인터랙션
|
|
21
|
+
╱──────────────╲ - Provider override로 상태 주입
|
|
22
|
+
╱ ╲
|
|
23
|
+
╱ Unit ╲ Unit Test
|
|
24
|
+
╱──────────────────╲ - Notifier, Repository, UseCase
|
|
25
|
+
- 순수 Dart (Flutter 의존성 없음)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Unit Test (Notifier)
|
|
29
|
+
|
|
30
|
+
```dart
|
|
31
|
+
void main() {
|
|
32
|
+
late MockMatchRepository mockRepo;
|
|
33
|
+
late ProviderContainer container;
|
|
34
|
+
|
|
35
|
+
setUp(() {
|
|
36
|
+
mockRepo = MockMatchRepository();
|
|
37
|
+
container = ProviderContainer(overrides: [
|
|
38
|
+
matchRepositoryProvider.overrideWithValue(mockRepo),
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
tearDown(() => container.dispose());
|
|
43
|
+
|
|
44
|
+
test('초기 로드 시 매치 목록 반환', () async {
|
|
45
|
+
when(() => mockRepo.getMatches(sport: Sport.tennis))
|
|
46
|
+
.thenAnswer((_) async => [testMatch]);
|
|
47
|
+
|
|
48
|
+
final notifier = container.read(matchListProvider.notifier);
|
|
49
|
+
final state = await container.read(matchListProvider.future);
|
|
50
|
+
|
|
51
|
+
expect(state, hasLength(1));
|
|
52
|
+
expect(state.first.id, testMatch.id);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Widget Test
|
|
58
|
+
|
|
59
|
+
```dart
|
|
60
|
+
void main() {
|
|
61
|
+
testWidgets('MatchCard가 매치 정보를 표시한다', (tester) async {
|
|
62
|
+
await tester.pumpWidget(
|
|
63
|
+
ProviderScope(
|
|
64
|
+
overrides: [
|
|
65
|
+
matchProvider.overrideWithValue(AsyncData(testMatch)),
|
|
66
|
+
],
|
|
67
|
+
child: const MaterialApp(
|
|
68
|
+
home: Scaffold(body: MatchCard()),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(find.text('Tennis Match'), findsOneWidget);
|
|
74
|
+
expect(find.text('Singapore Sports Hub'), findsOneWidget);
|
|
75
|
+
|
|
76
|
+
await tester.tap(find.byType(ElevatedButton));
|
|
77
|
+
await tester.pumpAndSettle();
|
|
78
|
+
|
|
79
|
+
expect(find.text('Joined!'), findsOneWidget);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Mock (mocktail)
|
|
85
|
+
|
|
86
|
+
```dart
|
|
87
|
+
import 'package:mocktail/mocktail.dart';
|
|
88
|
+
|
|
89
|
+
// 코드 생성 불필요
|
|
90
|
+
class MockMatchRepository extends Mock implements MatchRepository {}
|
|
91
|
+
class MockAuthService extends Mock implements AuthService {}
|
|
92
|
+
|
|
93
|
+
// 사용
|
|
94
|
+
when(() => mockRepo.getMatches(sport: any(named: 'sport')))
|
|
95
|
+
.thenAnswer((_) async => [testMatch]);
|
|
96
|
+
|
|
97
|
+
verify(() => mockRepo.getMatches(sport: Sport.tennis)).called(1);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Integration Test (Patrol)
|
|
101
|
+
|
|
102
|
+
```dart
|
|
103
|
+
// integration_test/match_flow_test.dart
|
|
104
|
+
patrolTest('매치 생성 → 참여 → 채팅 전체 플로우', ($) async {
|
|
105
|
+
await $.pumpWidgetAndSettle(const MyApp());
|
|
106
|
+
|
|
107
|
+
// 로그인
|
|
108
|
+
await $(#emailField).enterText('test@example.com');
|
|
109
|
+
await $(#passwordField).enterText('password');
|
|
110
|
+
await $(#loginButton).tap();
|
|
111
|
+
|
|
112
|
+
// 네이티브 권한 허용 (Patrol 고유 기능)
|
|
113
|
+
await $.native.grantPermissionWhenInUse();
|
|
114
|
+
|
|
115
|
+
// 매치 화면
|
|
116
|
+
await $.waitUntilVisible($(#matchList));
|
|
117
|
+
await $(#createMatchButton).tap();
|
|
118
|
+
|
|
119
|
+
// 확인
|
|
120
|
+
expect($(#matchCreatedSnackbar), findsOneWidget);
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 규칙
|
|
125
|
+
|
|
126
|
+
- Unit test: Notifier/Repository 100% 커버리지 목표
|
|
127
|
+
- Widget test: 주요 화면 + 사용자 인터랙션
|
|
128
|
+
- Mock: `mocktail` 사용 (mockito의 codegen 불필요)
|
|
129
|
+
- Provider 테스트: `ProviderContainer` + `overrides`
|
|
130
|
+
- Integration: 핵심 사용자 플로우만 (3-5개)
|
|
131
|
+
- test fixtures: `test/fixtures/` 에 공유 테스트 데이터
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Widget Conventions
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
tags: widget, composition, const, key
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Widget Conventions
|
|
8
|
+
|
|
9
|
+
Flutter 위젯은 합성(composition)으로 설계. 상속보다 조합, const로 성능 확보.
|
|
10
|
+
|
|
11
|
+
### 위젯 구조 템플릿
|
|
12
|
+
|
|
13
|
+
```dart
|
|
14
|
+
/// 사용자 프로필 카드.
|
|
15
|
+
class UserProfileCard extends StatelessWidget {
|
|
16
|
+
const UserProfileCard({
|
|
17
|
+
super.key,
|
|
18
|
+
required this.user,
|
|
19
|
+
this.onTap,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
final UserProfile user;
|
|
23
|
+
final VoidCallback? onTap;
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
Widget build(BuildContext context) {
|
|
27
|
+
final theme = Theme.of(context);
|
|
28
|
+
|
|
29
|
+
return Card(
|
|
30
|
+
child: InkWell(
|
|
31
|
+
onTap: onTap,
|
|
32
|
+
child: Padding(
|
|
33
|
+
padding: const EdgeInsets.all(16),
|
|
34
|
+
child: Row(
|
|
35
|
+
children: [
|
|
36
|
+
UserAvatar(url: user.avatarUrl),
|
|
37
|
+
const SizedBox(width: 12),
|
|
38
|
+
Expanded(
|
|
39
|
+
child: Column(
|
|
40
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
41
|
+
children: [
|
|
42
|
+
Text(user.name, style: theme.textTheme.titleMedium),
|
|
43
|
+
Text(user.email, style: theme.textTheme.bodySmall),
|
|
44
|
+
],
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### const 생성자
|
|
57
|
+
|
|
58
|
+
**Incorrect:**
|
|
59
|
+
```dart
|
|
60
|
+
class AppLogo extends StatelessWidget {
|
|
61
|
+
// const 없음 → 부모 리빌드마다 재생성
|
|
62
|
+
AppLogo({super.key});
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
Widget build(BuildContext context) {
|
|
66
|
+
return Image.asset('assets/logo.png', width: 48);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 사용 시
|
|
71
|
+
Column(children: [AppLogo(), ...]) // const 불가
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Correct:**
|
|
75
|
+
```dart
|
|
76
|
+
class AppLogo extends StatelessWidget {
|
|
77
|
+
const AppLogo({super.key});
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
Widget build(BuildContext context) {
|
|
81
|
+
return Image.asset('assets/logo.png', width: 48);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 사용 시
|
|
86
|
+
Column(children: [const AppLogo(), ...]) // 리빌드 스킵
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Key 사용 기준
|
|
90
|
+
|
|
91
|
+
**Incorrect:**
|
|
92
|
+
```dart
|
|
93
|
+
ListView(
|
|
94
|
+
children: items.map((item) => ItemTile(item: item)).toList(),
|
|
95
|
+
// Key 없음 → 순서 변경/삽입 시 위젯 상태 꼬임
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Correct:**
|
|
100
|
+
```dart
|
|
101
|
+
ListView(
|
|
102
|
+
children: items.map((item) => ItemTile(
|
|
103
|
+
key: ValueKey(item.id), // 고유 식별자
|
|
104
|
+
item: item,
|
|
105
|
+
)).toList(),
|
|
106
|
+
);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 분리 기준
|
|
110
|
+
|
|
111
|
+
- **200줄 초과** → 서브 위젯으로 추출
|
|
112
|
+
- **재사용 가능** → `shared/widgets/`로 이동
|
|
113
|
+
- **build() 안 조건 분기 3개 이상** → 별도 위젯
|
|
114
|
+
- **GlobalKey** → 거의 사용 금지 (Form 제외). `ValueKey`/`ObjectKey` 우선
|
|
115
|
+
|
|
116
|
+
### 규칙
|
|
117
|
+
|
|
118
|
+
- `const` 생성자 가능하면 항상 선언 (`prefer_const_constructors` 린트)
|
|
119
|
+
- `super.key` 파라미터 필수 (모든 위젯)
|
|
120
|
+
- trailing comma 필수 (위젯 트리 가독성)
|
|
121
|
+
- `build()` 안에서 데이터 가공/API 호출 금지
|
|
122
|
+
- `SizedBox` > `Container` (빈 공간에 Container 쓰지 않기)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security
|
|
3
|
+
description: |
|
|
4
|
+
Flutter 모바일 보안 가이드라인.
|
|
5
|
+
flutter_secure_storage, biometric 인증, SSL pinning,
|
|
6
|
+
코드 난독화, API 키 보호, 데이터 암호화.
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
tags: [flutter, security, encryption, biometric, ssl-pinning, obfuscation]
|
|
9
|
+
user-invocable: false
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Security
|
|
13
|
+
|
|
14
|
+
Flutter 모바일 보안 가이드. 저장소 암호화, 전송 보안, 실행 보호, 인증 강화.
|
|
15
|
+
OWASP MASVS 기준 L1/L2 준수를 목표로 레이어별 독립 방어.
|
|
16
|
+
|
|
17
|
+
## Philosophy
|
|
18
|
+
|
|
19
|
+
- 보안은 레이어 — 저장(암호화), 전송(TLS+pinning), 실행(난독화) 각각 독립 방어
|
|
20
|
+
- 키는 코드에 없다 — 빌드 시 주입, 런타임 안전 저장소
|
|
21
|
+
- 인증은 로컬 + 원격 이중 — biometric은 UX, 토큰은 보안
|
|
22
|
+
|
|
23
|
+
## Resources
|
|
24
|
+
|
|
25
|
+
6개 규칙 + 1개 참조. 모바일 보안 전체 레이어를 커버.
|
|
26
|
+
|
|
27
|
+
| Priority | Type | Resource | Description |
|
|
28
|
+
|----------|------|----------|-------------|
|
|
29
|
+
| CRITICAL | rule | [secure-storage](rules/secure-storage.md) | flutter_secure_storage (Keychain/EncryptedSharedPreferences), 민감 데이터 저장 |
|
|
30
|
+
| CRITICAL | rule | [authentication](rules/authentication.md) | 토큰 라이프사이클, 자동 갱신, biometric 인증, 세션 관리 |
|
|
31
|
+
| HIGH | rule | [ssl-pinning](rules/ssl-pinning.md) | Dio SecurityContext, 인증서/공개키 pinning, 핀 업데이트 전략 |
|
|
32
|
+
| HIGH | rule | [api-key-protection](rules/api-key-protection.md) | --dart-define 빌드 주입, envied 패키지, 하드코딩 금지 |
|
|
33
|
+
| HIGH | rule | [obfuscation](rules/obfuscation.md) | --obfuscate, ProGuard, dSYM, Crashlytics 심볼 업로드 |
|
|
34
|
+
| MEDIUM | rule | [data-protection](rules/data-protection.md) | 로컬 DB 암호화, 클립보드 보호, 스크린샷 방지 |
|
|
35
|
+
| — | ref | [mobile-security-checklist](references/mobile-security-checklist.md) | OWASP MASVS L1/L2 체크리스트, 추천 도구 |
|
|
36
|
+
|
|
37
|
+
## Quick Rules
|
|
38
|
+
|
|
39
|
+
### 저장소 보안
|
|
40
|
+
- 토큰/시크릿 → `flutter_secure_storage` (Keychain iOS / EncryptedSharedPreferences Android)
|
|
41
|
+
- `SharedPreferences`에 민감 데이터 저장 금지 — 평문 저장, 루팅 시 노출
|
|
42
|
+
- 로컬 DB → SQLCipher 또는 encrypt 패키지로 암호화
|
|
43
|
+
|
|
44
|
+
### 인증
|
|
45
|
+
- Access Token (단수명) + Refresh Token (장수명) 분리
|
|
46
|
+
- Dio interceptor로 401 감지 → 자동 갱신 → 재요청
|
|
47
|
+
- Biometric → `local_auth` 패키지, 실패 시 PIN/패턴 폴백
|
|
48
|
+
|
|
49
|
+
### 전송 보안
|
|
50
|
+
- TLS 1.2+ 필수, 인증서 pinning으로 MITM 차단
|
|
51
|
+
- Dio `SecurityContext` + `badCertificateCallback` 조합
|
|
52
|
+
- 핀 만료 대비 → 백업 핀 + 원격 업데이트 전략
|
|
53
|
+
|
|
54
|
+
### API 키 보호
|
|
55
|
+
- `--dart-define=API_KEY=xxx` → 빌드 시 주입
|
|
56
|
+
- `String.fromEnvironment('API_KEY')` → 런타임 참조
|
|
57
|
+
- `.env` → `envied` 패키지 (코드 생성, 난독화 옵션)
|
|
58
|
+
- 하드코딩 → 절대 금지 (git history에 영구 노출)
|
|
59
|
+
|
|
60
|
+
### 난독화
|
|
61
|
+
- `flutter build --obfuscate --split-debug-info=debug-info/`
|
|
62
|
+
- Android: ProGuard/R8 shrinking 활성화
|
|
63
|
+
- iOS: Bitcode (Xcode 설정) + dSYM 보관
|
|
64
|
+
- Crashlytics → 디버그 심볼 업로드 (크래시 리포트 해석용)
|
|
65
|
+
|
|
66
|
+
### 데이터 보호
|
|
67
|
+
- 스크린샷 방지 → Android `FLAG_SECURE`, iOS `UITextField` 오버레이
|
|
68
|
+
- 클립보드 → autofill 후 일정 시간 뒤 클리어
|
|
69
|
+
- 앱 백그라운드 진입 시 → 스크린 블러/오버레이
|
|
70
|
+
|
|
71
|
+
## Checklist
|
|
72
|
+
|
|
73
|
+
| Priority | Item |
|
|
74
|
+
|----------|------|
|
|
75
|
+
| CRITICAL | 토큰/시크릿은 flutter_secure_storage에만 저장 |
|
|
76
|
+
| CRITICAL | SharedPreferences에 민감 데이터 저장하지 않음 |
|
|
77
|
+
| CRITICAL | Access/Refresh Token 분리 + 자동 갱신 구현 |
|
|
78
|
+
| CRITICAL | API 키 하드코딩 없음 (--dart-define 또는 envied) |
|
|
79
|
+
| HIGH | SSL pinning 적용 (인증서 또는 공개키) |
|
|
80
|
+
| HIGH | 릴리즈 빌드 시 --obfuscate 플래그 사용 |
|
|
81
|
+
| HIGH | ProGuard/R8 shrinking 활성화 (Android) |
|
|
82
|
+
| HIGH | Biometric 인증 + PIN 폴백 구현 |
|
|
83
|
+
| MEDIUM | 로컬 DB 암호화 (SQLCipher) |
|
|
84
|
+
| MEDIUM | 스크린샷 방지 (FLAG_SECURE) 적용 |
|
|
85
|
+
| MEDIUM | Crashlytics 디버그 심볼 업로드 |
|
|
86
|
+
| MEDIUM | OWASP MASVS L1 체크리스트 통과 |
|