Skip to content

코드 템플릿

개발 시간을 단축하고 일관된 코드 스타일을 유지하기 위해 코드 템플릿을 활용하는 것은 매우 유용합니다. 이 페이지에서는 Flutter 개발에 도움이 되는 다양한 코드 템플릿과 예제를 제공합니다.

lib/
├── core/
│ ├── constants/
│ ├── exceptions/
│ ├── extensions/
│ ├── routes/
│ ├── services/
│ ├── theme/
│ └── utils/
├── data/
│ ├── datasources/
│ ├── models/
│ └── repositories/
├── domain/
│ ├── entities/
│ ├── repositories/ (interfaces)
│ └── usecases/
├── presentation/
│ ├── pages/
│ ├── providers/
│ ├── viewmodels/
│ └── widgets/
├── main.dart
└── app.dart

간소화된 구조 (소규모 프로젝트)

Section titled “간소화된 구조 (소규모 프로젝트)”
lib/
├── common/
│ ├── constants.dart
│ ├── theme.dart
│ └── utils.dart
├── data/
│ ├── models/
│ └── repositories/
├── providers/
├── screens/
│ ├── home/
│ ├── details/
│ └── settings/
├── widgets/
│ ├── common/
│ └── specialized/
├── main.dart
└── app.dart
class CustomWidget extends StatelessWidget {
final String title;
final VoidCallback onTap;
const CustomWidget({
super.key,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(8.0),
),
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
class CounterWidget extends StatefulWidget {
final int initialValue;
final ValueChanged<int>? onChanged;
const CounterWidget({
super.key,
this.initialValue = 0,
this.onChanged,
});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
late int _counter;
@override
void initState() {
super.initState();
_counter = widget.initialValue;
}
void _increment() {
setState(() {
_counter++;
widget.onChanged?.call(_counter);
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('카운터: $_counter'),
ElevatedButton(
onPressed: _increment,
child: const Text('증가'),
),
],
);
}
}
class HookCounter extends HookWidget {
final ValueChanged<int>? onChanged;
const HookCounter({
super.key,
this.onChanged,
});
@override
Widget build(BuildContext context) {
final counter = useState(0);
void increment() {
counter.value++;
onChanged?.call(counter.value);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('카운터: ${counter.value}'),
ElevatedButton(
onPressed: increment,
child: const Text('증가'),
),
],
);
}
}
// 프로바이더 정의
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
// 위젯 구현
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('카운터: $counter'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('증가'),
),
],
);
}
}

HookConsumerWidget 템플릿 (hooks_riverpod)

Section titled “HookConsumerWidget 템플릿 (hooks_riverpod)”
class HookConsumerCounter extends HookConsumerWidget {
const HookConsumerCounter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
final isActive = useState(true);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: isActive.value,
onChanged: (value) => isActive.value = value,
),
Text('카운터: $counter'),
ElevatedButton(
onPressed: isActive.value
? () => ref.read(counterProvider.notifier).increment()
: null,
child: const Text('증가'),
),
],
);
}
}
user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
@JsonKey(name: 'email_address') required String email,
String? profileUrl,
@Default(false) bool isPremium,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

상태를 가진 Freezed 모델 (AsyncValue 활용)

Section titled “상태를 가진 Freezed 모델 (AsyncValue 활용)”
user_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user.dart';
part 'user_state.freezed.dart';
@freezed
class UserState with _$UserState {
const factory UserState({
required AsyncValue<User> user,
@Default(false) bool isEditing,
}) = _UserState;
const UserState._();
factory UserState.initial() => UserState(
user: const AsyncValue.loading(),
);
bool get isLoading => user.isLoading;
bool get hasError => user.hasError;
UserState copyWithUser(User? newUser) {
return copyWith(
user: newUser != null
? AsyncValue.data(newUser)
: const AsyncValue.loading(),
);
}
}
final configProvider = Provider<AppConfig>((ref) {
return AppConfig(
apiUrl: 'https://api.example.com',
timeout: const Duration(seconds: 30),
);
});
counter_notifier.dart
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void decrement() {
if (state > 0) state--;
}
void reset() => state = 0;
}
// counter_provider.dart
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
users_notifier.dart
@riverpod
class UsersNotifier extends _$UsersNotifier {
@override
FutureOr<List<User>> build() async {
return _fetchUsers();
}
Future<List<User>> _fetchUsers() async {
final apiService = ref.read(apiServiceProvider);
return await apiService.getUsers();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetchUsers);
}
Future<void> addUser(User user) async {
state = const AsyncValue.loading();
final apiService = ref.read(apiServiceProvider);
await apiService.addUser(user);
state = await AsyncValue.guard(_fetchUsers);
}
}
// 단순 FutureProvider
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final apiService = ref.read(apiServiceProvider);
return await apiService.getUserById(userId);
});
// 스트림 프로바이더
final messagesProvider = StreamProvider<List<Message>>((ref) {
final chatService = ref.read(chatServiceProvider);
return chatService.getMessagesStream();
});
class ProfileScreen extends StatelessWidget {
final String userId;
const ProfileScreen({
super.key,
required this.userId,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('프로필'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
// 설정 화면으로 이동
},
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 프로필 헤더
const ProfileHeader(),
const SizedBox(height: 16),
// 탭 컨트롤
const ProfileTabBar(),
// 탭 콘텐츠
Expanded(
child: ProfileTabView(userId: userId),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 작업 수행
},
child: const Icon(Icons.add),
),
bottomNavigationBar: BottomNavigationBar(
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '홈',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: '설정',
),
],
currentIndex: 1,
onTap: (index) {
// 탭 변경 처리
},
),
);
}
}
class ResponsiveLayout extends StatelessWidget {
final Widget mobile;
final Widget? tablet;
final Widget? desktop;
const ResponsiveLayout({
super.key,
required this.mobile,
this.tablet,
this.desktop,
});
static bool isMobile(BuildContext context) =>
MediaQuery.of(context).size.width < 650;
static bool isTablet(BuildContext context) =>
MediaQuery.of(context).size.width >= 650 &&
MediaQuery.of(context).size.width < 1100;
static bool isDesktop(BuildContext context) =>
MediaQuery.of(context).size.width >= 1100;
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.of(context).size;
if (size.width >= 1100 && desktop != null) {
return desktop!;
}
if (size.width >= 650 && tablet != null) {
return tablet!;
}
return mobile;
}
}
// 사용 예시
class MyResponsiveScreen extends StatelessWidget {
const MyResponsiveScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('반응형 예제')),
body: const ResponsiveLayout(
mobile: MobileLayout(),
tablet: TabletLayout(),
desktop: DesktopLayout(),
),
);
}
}

REST API 클라이언트

class ApiClient {
final Dio _dio;
ApiClient() : _dio = Dio() {
_dio.options.baseUrl = 'https://api.example.com';
_dio.options.connectTimeout = const Duration(seconds: 5);
_dio.options.receiveTimeout = const Duration(seconds: 3);
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<T> post<T>(
String path, {
required dynamic data,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.post(
path,
data: data,
);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
}
}
Exception _handleError(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
return TimeoutException('연결 시간이 초과되었습니다');
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
if (statusCode == 401) {
return UnauthorizedException('인증이 필요합니다');
} else if (statusCode == 404) {
return NotFoundException('요청한 리소스를 찾을 수 없습니다');
}
return ServerException('서버 오류가 발생했습니다: $statusCode');
default:
return Exception('네트워크 오류가 발생했습니다: ${e.message}');
}
}
}
class GraphQLClient {
final HttpLink _httpLink;
late final graphql.GraphQLClient _client;
GraphQLClient() : _httpLink = HttpLink('https://api.example.com/graphql') {
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer $token',
);
final Link link = authLink.concat(_httpLink);
_client = graphql.GraphQLClient(
link: link,
cache: GraphQLCache(),
);
}
Future<T> query<T>({
required String queryDocument,
Map<String, dynamic>? variables,
required T Function(Map<String, dynamic> data) fromJson,
}) async {
final QueryOptions options = QueryOptions(
document: gql.gql(queryDocument),
variables: variables ?? {},
);
final QueryResult result = await _client.query(options);
if (result.hasException) {
throw _handleException(result.exception!);
}
return fromJson(result.data!);
}
Future<T> mutate<T>({
required String mutationDocument,
Map<String, dynamic>? variables,
required T Function(Map<String, dynamic> data) fromJson,
}) async {
final MutationOptions options = MutationOptions(
document: gql.gql(mutationDocument),
variables: variables ?? {},
);
final QueryResult result = await _client.mutate(options);
if (result.hasException) {
throw _handleException(result.exception!);
}
return fromJson(result.data!);
}
Exception _handleException(OperationException exception) {
if (exception.linkException != null) {
return NetworkException('네트워크 오류가 발생했습니다');
}
if (exception.graphqlErrors.isNotEmpty) {
final firstError = exception.graphqlErrors.first;
return GraphQLException(firstError.message);
}
return Exception('알 수 없는 오류가 발생했습니다');
}
}

Riverpod + Freezed를 활용한 상태 관리

Section titled “Riverpod + Freezed를 활용한 상태 관리”
todo_state.dart
@freezed
class TodoState with _$TodoState {
const factory TodoState({
required AsyncValue<List<Todo>> todos,
@Default('') String newTodoText,
Todo? editingTodo,
}) = _TodoState;
factory TodoState.initial() => TodoState(
todos: const AsyncValue.loading(),
);
}
// todo_notifier.dart
@riverpod
class TodoNotifier extends _$TodoNotifier {
@override
TodoState build() {
_loadTodos();
return TodoState.initial();
}
Future<void> _loadTodos() async {
state = state.copyWith(todos: const AsyncValue.loading());
try {
final todoRepository = ref.read(todoRepositoryProvider);
final todos = await todoRepository.getTodos();
state = state.copyWith(todos: AsyncValue.data(todos));
} catch (error, stackTrace) {
state = state.copyWith(todos: AsyncValue.error(error, stackTrace));
}
}
void setNewTodoText(String text) {
state = state.copyWith(newTodoText: text);
}
Future<void> addTodo() async {
if (state.newTodoText.trim().isEmpty) return;
final todo = Todo(
id: const Uuid().v4(),
title: state.newTodoText,
completed: false,
);
final currentTodos = state.todos.valueOrNull ?? [];
// 낙관적 업데이트
state = state.copyWith(
todos: AsyncValue.data([...currentTodos, todo]),
newTodoText: '',
);
try {
final todoRepository = ref.read(todoRepositoryProvider);
await todoRepository.addTodo(todo);
} catch (error) {
// 실패 시 롤백
state = state.copyWith(
todos: AsyncValue.data(currentTodos),
);
// 오류 메시지 표시
}
}
}
router.dart
final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'root');
final GlobalKey<NavigatorState> shellNavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'shell');
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: '/',
debugLogDiagnostics: true,
redirect: (context, state) {
final isLoggedIn = authState.valueOrNull?.user != null;
final isLoggingIn = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) return '/login';
if (isLoggedIn && isLoggingIn) return '/';
return null;
},
routes: [
// 로그인 화면
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
// 쉘 라우트 (하단 탐색바)
ShellRoute(
navigatorKey: shellNavigatorKey,
builder: (context, state, child) => ShellScreen(child: child),
routes: [
// 홈 화면
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
// 상세 화면
GoRoute(
path: 'details/:id',
builder: (context, state) {
final id = state.params['id']!;
return DetailsScreen(id: id);
},
),
],
),
// 프로필 화면
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
// 설정 화면
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
errorBuilder: (context, state) => ErrorScreen(error: state.error),
);
});
void main() {
group('Calculator 테스트', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('더하기 테스트', () {
expect(calculator.add(1, 2), 3);
expect(calculator.add(-1, 1), 0);
expect(calculator.add(0, 0), 0);
});
test('나누기 테스트', () {
expect(calculator.divide(6, 2), 3);
expect(calculator.divide(5, 2), 2.5);
expect(
() => calculator.divide(1, 0),
throwsA(isA<DivisionByZeroException>()),
);
});
});
}
counter_test.dart
void main() {
group('CounterNotifier 테스트', () {
late ProviderContainer container;
setUp(() {
container = ProviderContainer();
});
tearDown(() {
container.dispose();
});
test('초기 상태는 0이다', () {
expect(container.read(counterProvider), 0);
});
test('increment 메서드는 상태를 1 증가시킨다', () {
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 2);
});
test('decrement 메서드는 상태를 1 감소시킨다', () {
container.read(counterProvider.notifier).increment();
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 2);
container.read(counterProvider.notifier).decrement();
expect(container.read(counterProvider), 1);
});
test('decrement 메서드는 상태가 0일 때 감소시키지 않는다', () {
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).decrement();
expect(container.read(counterProvider), 0);
});
});
}
void main() {
group('Counter 위젯 테스트', () {
testWidgets('초기 카운터 값이 올바르게 표시된다', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: CounterWidget(initialValue: 5),
),
),
);
expect(find.text('카운터: 5'), findsOneWidget);
expect(find.text('증가'), findsOneWidget);
});
testWidgets('버튼 클릭 시 카운터가 증가한다', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: CounterWidget(),
),
),
);
expect(find.text('카운터: 0'), findsOneWidget);
await tester.tap(find.text('증가'));
await tester.pump();
expect(find.text('카운터: 1'), findsOneWidget);
});
testWidgets('onChanged 콜백이 호출된다', (WidgetTester tester) async {
int? newValue;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CounterWidget(
onChanged: (value) {
newValue = value;
},
),
),
),
);
await tester.tap(find.text('증가'));
await tester.pump();
expect(newValue, 1);
});
});
}

다음은 더 많은 코드 예제를 찾을 수 있는 유용한 리소스 모음입니다:

이 리소스들은 다양한 Flutter 프로젝트와 코드 예제를 제공하여 개발자가 더 빠르게 학습하고 개발할 수 있도록 도와줍니다.