figma-to-coui Templates
Ready-to-use code templates that can be copied directly (bare-widget standard).
기준: coui_flutter 0.163 · coui_core 0.162 (
coui_skills_basis, v2). 상세 API는 cc-coui 플러그인의 개별 스킬을 1차 소스로 참조한다.
Ready-to-use code templates that can be copied directly (bare-widget standard). Customization points are marked with comments.
All components are bare-named (
Button/Badge/Icon/Gap/TextField) — v0.127 removed theCoprefix, noCo<X>alias remains. Spacing usesGap.spaceN()token constructors, and text picks a role through theTextchain (Text('…').titleMedium). Import CoUI types throughpackage:core/core.dart.
1. Basic Page Template#
BLoC Page#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Page description
class MyPage extends HookWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
// [TODO] Change BLoC type
create: (_) => MyBloc()..add(const MyEvent.started()),
child: Scaffold(
headers: [
AppBar(
// [TODO] Change page title
title: Text('페이지 제목'),
leading: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const Icon(LucideIcons.arrowLeft),
),
],
// [TODO] Add trailing actions if needed
trailing: const [],
),
],
child: const _Body(),
),
);
}
}
class _Body extends StatelessWidget {
const _Body();
@override
Widget build(BuildContext context) {
final colorScheme = context.theme.colorScheme;
return BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
// [TODO] UI branching by state
return switch (state.status) {
LoadingStatus.initial || LoadingStatus.loading =>
const Center(child: Loading()),
LoadingStatus.failure => _ErrorView(message: state.errorMessage),
LoadingStatus.success => _SuccessView(data: state.data),
};
},
);
}
}
class _SuccessView extends StatelessWidget {
const _SuccessView({required this.data});
final MyData data;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap.space16(),
// [TODO] Implement content
Text('컨텐츠').titleMedium,
],
),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.circleAlert,
iconStyle: CoreIconStyle(size: 48),
),
const Gap.space16(),
Text(message).bodyMedium,
const Gap.space16(),
Button(
onPressed: () {
context.read<MyBloc>().add(const MyEvent.retried());
},
child: const Text('다시 시도'),
),
],
),
);
}
}
Simple Page (Local State Only)#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Page description
class SimplePage extends HookWidget {
const SimplePage({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = context.theme.colorScheme;
// [TODO] Define local state
final selectedIndex = useState(0);
final isExpanded = useState(false);
return Scaffold(
headers: [
AppBar(
title: Text('페이지 제목'),
leading: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const Icon(LucideIcons.arrowLeft),
),
],
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
children: [
const Gap.space16(),
// [TODO] Implement content
],
),
),
);
}
}
2. Form Page Template#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Form page description
class MyFormPage extends HookWidget {
const MyFormPage({super.key});
@override
Widget build(BuildContext context) {
// [TODO] Define controllers
final nameController = useTextEditingController();
final emailController = useTextEditingController();
// Field errors live in state and show through FormField.error —
// CoUI Form lays the rows out; it does not validate.
final nameError = useState<String?>(null);
final emailError = useState<String?>(null);
final isLoading = useState(false);
bool validate() {
final email = emailController.text;
nameError.value = nameController.text.isEmpty ? '이름을 입력하세요' : null;
emailError.value = email.isEmpty
? '이메일을 입력하세요'
: !email.contains('@')
? '올바른 이메일 형식이 아닙니다'
: null;
return nameError.value == null && emailError.value == null;
}
Future<void> submit() async {
if (!validate()) return;
isLoading.value = true;
try {
// [TODO] Implement submit logic
await Future<void>.delayed(const Duration(seconds: 1));
if (context.mounted) {
context.pop();
}
} finally {
isLoading.value = false;
}
}
return Scaffold(
headers: [
AppBar(
title: Text('폼 제목'),
leading: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const Icon(LucideIcons.x),
),
],
),
],
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap.space16(),
// [TODO] Define form fields
Form(
rowSpacing: CoreSpace.space16,
children: [
FormField(
label: '이름',
required: true,
error: nameError.value,
child: TextField(
controller: nameController,
placeholder: const Text('이름을 입력하세요'),
),
),
FormField(
label: '이메일',
required: true,
error: emailError.value,
child: TextField(
controller: emailController,
type: CoreInputType.email,
placeholder: const Text('이메일을 입력하세요'),
),
),
],
),
const Spacer(),
// 제출 버튼
Button(
expanded: true,
enabled: !isLoading.value,
onPressed: submit,
child: isLoading.value ? const Loading() : const Text('제출'),
),
const Gap.space16(),
],
),
),
),
);
}
}
3. List Page Template#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] List page description
class MyListPage extends HookWidget {
const MyListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => MyListBloc()..add(const MyListEvent.started()),
child: Scaffold(
headers: [
AppBar(
title: Text('목록'),
trailing: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.push('/create'),
child: const Icon(LucideIcons.plus),
),
],
),
],
child: const _Body(),
),
);
}
}
class _Body extends HookWidget {
const _Body();
@override
Widget build(BuildContext context) {
final scrollController = useScrollController();
// Infinite scroll
useEffect(() {
void onScroll() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
context.read<MyListBloc>().add(const MyListEvent.loadMore());
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, []);
return BlocSignalBuilder<MyListBloc, MyListState>(
builder: (context, state) {
if (state.isInitialLoading) {
return const Center(child: Loading());
}
if (state.items.isEmpty) {
return const _EmptyView();
}
return RefreshTrigger(
onRefresh: () async {
context.read<MyListBloc>().add(const MyListEvent.refreshed());
await context.read<MyListBloc>().stream.firstWhere(
(s) => !s.isRefreshing,
);
},
child: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.all(CoreSpace.space16),
itemCount: state.items.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, __) => const Gap.space8(),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(CoreSpace.space16),
child: Loading(),
),
);
}
return _ItemCard(item: state.items[index]);
},
),
);
},
);
}
}
class _ItemCard extends StatelessWidget {
const _ItemCard({required this.item});
// [TODO] Change item type
final MyItem item;
@override
Widget build(BuildContext context) {
// A tappable surface is a Card — it pads and draws itself
return Card(
onTap: () => context.push('/detail/${item.id}'),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title).titleSmall,
const Gap.space4(),
Text(item.subtitle).bodySmall.onSurfaceVariant,
],
),
),
const Icon(
LucideIcons.chevronRight,
iconStyle: CoreIconStyle(color: CoreColor.token(CoreColors.onSurfaceVariant)),
),
],
),
);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) {
return const EmptyState(
icon: Icon(LucideIcons.inbox),
title: Text('항목이 없습니다'),
description: Text('새 항목을 추가해보세요'),
);
}
}
4. Tab Page Template#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Tab page description
class MyTabPage extends HookWidget {
const MyTabPage({super.key});
@override
Widget build(BuildContext context) {
final selectedTab = useState(0);
// [TODO] Define tabs
final tabs = [
_TabInfo(title: '첫 번째', icon: LucideIcons.house),
_TabInfo(title: '두 번째', icon: LucideIcons.user),
_TabInfo(title: '세 번째', icon: LucideIcons.settings),
];
return Scaffold(
headers: [
AppBar(
title: Text('탭 페이지'),
),
],
child: Column(
children: [
// Tab bar
Tabs(
tabs: [
for (final tab in tabs)
CoreTabItem(label: tab.title, leading: Icon(tab.icon)),
],
selectedIndex: selectedTab.value,
onChanged: (index) => selectedTab.value = index,
),
// Tab content
Expanded(
child: IndexedStack(
index: selectedTab.value,
children: const [
_FirstTabContent(),
_SecondTabContent(),
_ThirdTabContent(),
],
),
),
],
),
);
}
}
class _TabInfo {
const _TabInfo({required this.title, required this.icon});
final String title;
final IconName icon;
}
class _FirstTabContent extends StatelessWidget {
const _FirstTabContent();
@override
Widget build(BuildContext context) {
return const Center(
child: Text('첫 번째 탭 컨텐츠'),
);
}
}
class _SecondTabContent extends StatelessWidget {
const _SecondTabContent();
@override
Widget build(BuildContext context) {
return const Center(
child: Text('두 번째 탭 컨텐츠'),
);
}
}
class _ThirdTabContent extends StatelessWidget {
const _ThirdTabContent();
@override
Widget build(BuildContext context) {
return const Center(
child: Text('세 번째 탭 컨텐츠'),
);
}
}
5. Card Widget Template#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Card description
class MyCard extends StatelessWidget {
const MyCard({
required this.title,
required this.subtitle,
this.onTap,
super.key,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
// A tappable surface is a Card — it pads and draws itself
return Card(
onTap: onTap,
child: Row(
children: [
// Icon area — a round icon slot is an Avatar
const Avatar(child: Icon(LucideIcons.star)),
const Gap.space12(),
// Text area
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title).titleSmall,
const Gap.space4(),
Text(subtitle).bodySmall.onSurfaceVariant,
],
),
),
// Arrow
const Icon(
LucideIcons.chevronRight,
iconStyle: CoreIconStyle(
size: 20,
color: CoreColor.token(CoreColors.onSurfaceVariant),
),
),
],
),
);
}
}
6. Dialog Template#
Confirmation Dialog#
/// Show confirmation dialog
Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String message,
String confirmText = '확인',
String cancelText = '취소',
bool isDestructive = false,
}) async {
// The barrier, motion and panel come from the dialog style — no raw colour here
final result = await showDialog<bool>(
context: context,
builder: (context) => Dialog(
title: Text(title),
content: Text(message),
actions: [
Button(
variant: .ghost,
onPressed: () => Navigator.pop(context, false),
child: Text(cancelText),
),
Button(
variant: isDestructive ? .destructive : .primary,
onPressed: () => Navigator.pop(context, true),
child: Text(confirmText),
),
],
),
);
return result ?? false;
}
// Usage example
void onDeleteTap(BuildContext context) async {
final confirmed = await showConfirmDialog(
context,
title: '삭제 확인',
message: '정말 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.',
confirmText: '삭제',
cancelText: '취소',
isDestructive: true,
);
if (confirmed && context.mounted) {
context.read<MyBloc>().add(const MyEvent.deleted());
}
}
Input Dialog#
/// Show input dialog
Future<String?> showInputDialog(
BuildContext context, {
required String title,
String? initialValue,
String? placeholder,
String confirmText = '확인',
String cancelText = '취소',
}) async {
final controller = TextEditingController(text: initialValue);
final result = await showDialog<String>(
context: context,
builder: (context) => Dialog(
title: Text(title),
content: TextField(
controller: controller,
placeholder: placeholder == null ? null : Text(placeholder),
),
actions: [
Button(
variant: .ghost,
onPressed: () => Navigator.pop(context),
child: Text(cancelText),
),
Button(
onPressed: () => Navigator.pop(context, controller.text),
child: Text(confirmText),
),
],
),
);
controller.dispose();
return result;
}
7. BottomSheet Template#
Option Selection BottomSheet#
/// Show option selection BottomSheet — a Drawer docked at the bottom.
/// The panel, its corners, the safe area and drag-to-dismiss come from the
/// drawer; the rows are MenuItems.
Future<T?> showOptionsSheet<T>(
BuildContext context, {
required String title,
required List<OptionItem<T>> options,
}) {
return openDrawer<T>(
context: context,
side: CoreDrawerSide.bottom,
builder: (sheetContext) => Drawer(
side: CoreDrawerSide.bottom,
title: title,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final option in options)
MenuItem(
leading: option.icon != null ? Icon(option.icon!) : null,
label: Text(option.label),
onPressed: () => closeDrawer<T>(sheetContext, option.value),
),
],
),
),
);
}
class OptionItem<T> {
const OptionItem({
required this.label,
required this.value,
this.icon,
});
final String label;
final T value;
final IconName? icon;
}
// Usage example
void onSortTap(BuildContext context) async {
final result = await showOptionsSheet<SortOrder>(
context,
title: '정렬 기준',
options: [
OptionItem(label: '최신순', value: SortOrder.newest, icon: LucideIcons.arrowDown),
OptionItem(label: '오래된순', value: SortOrder.oldest, icon: LucideIcons.arrowUp),
OptionItem(label: '이름순', value: SortOrder.name, icon: LucideIcons.list),
],
);
if (result != null && context.mounted) {
context.read<MyBloc>().add(MyEvent.sorted(result));
}
}
8. Route Definition Template#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
part 'my_feature_route.g.dart';
/// [TODO] Route description
@TypedGoRoute<MyFeatureRoute>(
path: '/my-feature',
routes: [
TypedGoRoute<MyFeatureDetailRoute>(path: ':id'),
TypedGoRoute<MyFeatureCreateRoute>(path: 'create'),
],
)
class MyFeatureRoute extends GoRouteData {
const MyFeatureRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const MyFeaturePage();
}
}
class MyFeatureDetailRoute extends GoRouteData {
const MyFeatureDetailRoute({required this.id});
final String id;
@override
Widget build(BuildContext context, GoRouterState state) {
return MyFeatureDetailPage(id: id);
}
}
class MyFeatureCreateRoute extends GoRouteData {
const MyFeatureCreateRoute({this.parentId});
final String? parentId;
@override
Widget build(BuildContext context, GoRouterState state) {
return MyFeatureCreatePage(parentId: parentId);
}
}
9. BLoC Event/State Template#
Event Definition#
import 'package:dependencies/dependencies.dart';
part 'my_event.freezed.dart';
@freezed
sealed class MyEvent with _$MyEvent {
const factory MyEvent.started() = _Started;
const factory MyEvent.refreshed() = _Refreshed;
const factory MyEvent.loadMore() = _LoadMore;
const factory MyEvent.itemSelected(String id) = _ItemSelected;
const factory MyEvent.submitted() = _Submitted;
}
State Definition#
import 'package:dependencies/dependencies.dart';
part 'my_state.freezed.dart';
@freezed
abstract class MyState with _$MyState {
const factory MyState({
@Default(LoadingStatus.initial) LoadingStatus status,
@Default([]) List<MyItem> items,
@Default(false) bool hasMore,
@Default('') String errorMessage,
MyItem? selectedItem,
}) = _MyState;
}
enum LoadingStatus { initial, loading, success, failure }
extension MyStateX on MyState {
bool get isInitialLoading => status == LoadingStatus.initial || status == LoadingStatus.loading;
bool get isRefreshing => status == LoadingStatus.loading && items.isNotEmpty;
bool get hasError => status == LoadingStatus.failure;
}
BLoC Definition#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] BLoC description
class MyBloc extends BlocSignal<MyEvent, MyState> {
/// Creates [MyBloc] and sets initial state.
MyBloc() : super(initialState: const MyState()) {
on<_Started>(_onStarted);
on<_Refreshed>(_onRefreshed);
on<_LoadMore>(_onLoadMore);
}
Future<void> _onStarted(_Started event, void Function(MyState) emit) async {
emit(stateValue.copyWith(status: LoadingStatus.loading));
// [TODO] Implement data load logic
try {
final items = await _fetchItems();
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.success,
items: items,
hasMore: items.length >= 20,
));
} on Exception catch (e) {
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.failure,
errorMessage: e.toString(),
));
}
}
Future<void> _onRefreshed(_Refreshed event, void Function(MyState) emit) async {
emit(stateValue.copyWith(status: LoadingStatus.loading));
try {
final items = await _fetchItems();
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.success,
items: items,
hasMore: items.length >= 20,
));
} on Exception {
if (isClosed) return;
emit(stateValue.copyWith(status: LoadingStatus.failure));
}
}
Future<void> _onLoadMore(_LoadMore event, void Function(MyState) emit) async {
if (!stateValue.hasMore || stateValue.status == LoadingStatus.loading) return;
try {
final newItems = await _fetchItems(offset: stateValue.items.length);
if (isClosed) return;
emit(stateValue.copyWith(
items: [...stateValue.items, ...newItems],
hasMore: newItems.length >= 20,
));
} on Exception {
// Infinite scroll 실패는 무시
}
}
// [TODO] Replace with actual data load logic
Future<List<MyItem>> _fetchItems({int offset = 0}) async {
// API call or Repository usage
return [];
}
}
Usage#
- Select the appropriate template.
- Find
[TODO]comments and customize for your project. - Change types, names, and logic.
- Delete unnecessary parts.