From ecc
null safety, 불변 상태(immutable state), 비동기 구성(async composition), 위젯 아키텍처, 주요 상태 관리 프레임워크(BLoC, Riverpod, Provider), GoRouter 내비게이션, Dio 네트워킹, Freezed 코드 생성 및 클린 아키텍처를 포함하는 프로덕션 레벨의 Dart 및 Flutter 패턴입니다.
npx claudepluginhub sam42-lab/everything-claude-code-krThis skill uses the workspace's default tool permissions.
이 스킬은 다음과 같은 경우에 사용하세요:
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
이 스킬은 다음과 같은 경우에 사용하세요:
이 스킬은 관심사별로 정리된, 바로 복사해서 사용할 수 있는 Dart/Flutter 코드 패턴을 제공합니다:
! 사용을 피하고, ?./??/패턴 매칭을 선호합니다.freezed, copyWith를 사용합니다.Future.wait, await 이후 안전한 BuildContext 사용 등을 다룹니다.const 전파, 범위별 리빌드(scoped rebuilds)를 실천합니다.refreshListenable을 통한 반응형 인증 가드를 포함한 GoRouter를 사용합니다.ErrorWidget.builder, Crashlytics 연결을 다룹니다.// Sealed state — 불가능한 상태를 방지
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
// 반응형 인증 리다이렉트가 포함된 GoRouter
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
return null;
},
routes: [...],
);
// 안전한 firstWhereOrNull을 사용한 Riverpod 파생 프로바이더
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
Dart 및 Flutter 애플리케이션을 위한 실용적이고 프로덕션에 즉시 적용 가능한 패턴입니다. 가능한 한 라이브러리에 구애받지 않으면서도, 가장 일반적인 에코시스템 패키지들을 명확하게 다룹니다.
!) 대신 패턴 사용 선호// 나쁨 — null일 경우 런타임에 크래시 발생
final name = user!.name;
// 좋음 — 폴백(fallback) 제공
final name = user?.name ?? 'Unknown';
// 좋음 — Dart 3 패턴 매칭 (복잡한 경우에 선호됨)
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Guest',
};
// 좋음 — 가드 문을 통한 조기 반환
String getUserName(User? user) {
if (user == null) return 'Unknown';
return user.name; // 체크 후 non-null로 승격(promotion)됨
}
late 남용 피하기// 나쁨 — null 에러를 런타임으로 미룸
late String userId;
// 좋음 — 명시적 초기화가 가능한 nullable 사용
String? userId;
// 확인 — 첫 접근 전에 초기화가 보장될 때만 late 사용
// (예: 위젯 상호작용 전 initState() 내에서)
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// 철저한(Exhaustive) switch — 컴파일러가 모든 분기 처리를 강제함
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// 사용 예시
final user = User(id: '1', name: 'Alice', email: 'alice@example.com');
final updated = user.copyWith(name: 'Alice Smith'); // 불변 업데이트
final json = user.toJson();
final fromJson = User.fromJson(json);
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
// 순차적으로 await하지 않고 병렬로 실행
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Dart 3 레코드 구조 분해 + Future.wait 확장 메서드
return DashboardData(users: userList, orders: orderList);
}
// 저장소(Repository)에서 실시간 데이터를 위한 반응형 스트림 노출
Stream<List<Item>> watchCartItems() => _db
.watchTable('cart_items')
.map((rows) => rows.map(Item.fromRow).toList());
// 위젯 레이어 — 선언적이며 수동 구독이 필요 없음
StreamBuilder<List<Item>>(
stream: cartRepository.watchCartItems(),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
AsyncSnapshot(:final data?) => CartList(items: data),
_ => const SizedBox.shrink(),
},
)
// 중요 — StatefulWidget의 await 이후에는 항상 mounted 여부를 확인
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // ← context를 사용하기 전 가드 코드
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// 나쁨 — 위젯을 반환하는 프라이빗 메서드. 최적화를 방해함
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
// 좋음 — 별도의 위젯 클래스. const 사용 및 엘리먼트 재사용 가능
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
}
// 나쁨 — 리빌드될 때마다 새로운 인스턴스 생성
child: Padding(
padding: EdgeInsets.all(16.0), // const가 아님
child: Icon(Icons.home, size: 24.0), // const가 아님
)
// 좋음 — const가 리빌드 전파를 중단시킴
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.home, size: 24.0),
)
// 나쁨 — 카운터가 변경될 때마다 페이지 전체가 리빌드됨
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // 전체를 리빌드함
return Scaffold(
body: Column(children: [
const ExpensiveHeader(), // 불필요하게 리빌드됨
Text('$count'),
const ExpensiveFooter(), // 불필요하게 리빌드됨
]),
);
}
}
// 좋음 — 리빌드되는 부분을 격리
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [
ExpensiveHeader(), // 절대 리빌드되지 않음 (const)
_CounterDisplay(), // 이 부분만 리빌드됨
ExpensiveFooter(), // 절대 리빌드되지 않음 (const)
]),
);
}
}
class _CounterDisplay extends ConsumerWidget {
const _CounterDisplay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
// Cubit — 동기 또는 간단한 비동기 상태
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
void logout() {
_authService.logout();
emit(const AuthState.initial());
}
}
// 위젯 내에서 사용
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthError(:final message) => ErrorView(message: message),
},
)
// 자동 해제(Auto-dispose) 비동기 프로바이더
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getAll();
}
// 복잡한 변이(mutation)를 포함하는 노티파이어
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void add(Product product) {
final existing = state.where((i) => i.productId == product.id).firstOrNull;
if (existing != null) {
state = [
for (final item in state)
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
else item,
];
} else {
state = [...state, CartItem(productId: product.id, quantity: 1)];
}
}
void remove(String productId) =>
state = state.where((i) => i.productId != productId).toList();
void clear() => state = [];
}
// 파생 프로바이더 (셀렉터 패턴)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
// firstWhereOrNull (collection 패키지)을 사용하여 제품이 없을 때의 StateError 방지
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
final router = GoRouter(
initialLocation: '/',
// refreshListenable은 인증 상태가 변경될 때마다 리다이렉트를 재평가함
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) return '/login';
if (isLoggedIn && isGoingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/products/:id',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
],
);
final dio = Dio(BaseOptions(
baseUrl: const String.fromEnvironment('API_URL'),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// 인증 인터셉터 추가
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await secureStorage.read(key: 'auth_token');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) async {
// 무한 재시도 루프 방지: 요청당 한 번만 갱신 시도
final isRetry = error.requestOptions.extra['_isRetry'] == true;
if (!isRetry && error.response?.statusCode == 401) {
final refreshed = await attemptTokenRefresh();
if (refreshed) {
error.requestOptions.extra['_isRetry'] = true;
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
));
// Dio를 사용하는 저장소(Repository)
class UserApiDataSource {
const UserApiDataSource(this._dio);
final Dio _dio;
Future<User> getById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return User.fromJson(response.data!);
}
}
// 전역 에러 캡처 — main()에서 설정
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
// 프로덕션용 커스텀 ErrorWidget
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
ErrorWidget.builder = (details) => ProductionErrorWidget(details);
return MaterialApp.router(routerConfig: router);
}
}
// 유닛 테스트 — Use case
test('GetUserUseCase returns null for missing user', () async {
final repo = FakeUserRepository();
final useCase = GetUserUseCase(repo);
expect(await useCase('missing-id'), isNull);
});
// BLoC 테스트
blocTest<AuthCubit, AuthState>(
'emits loading then error on failed login',
build: () => AuthCubit(FakeAuthService(throwsOn: 'login')),
act: (cubit) => cubit.login('user@test.com', 'wrong'),
expect: () => [const AuthState.loading(), isA<AuthError>()],
);
// 위젯 테스트
testWidgets('CartBadge shows item count', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier(count: 3))],
child: const MaterialApp(home: CartBadge()),
),
);
expect(find.text('3'), findsOneWidget);
});
flutter-dart-code-review — 종합 리뷰 체크리스트rules/dart/ — 코딩 스타일, 패턴, 보안, 테스트, 훅