figma-to-coui Reference
Provides detailed descriptions, example code, and precautions for each Use Case.
기준: coui v0.127.15 (bare-widget, Co 접두어 제거 세대). 상세 API는 cc-coui 플러그인의 개별 스킬을 1차 소스로 참조한다.
Provides detailed descriptions, example code, and precautions for each Use Case.
1. Page Structure#
Overview#
All pages follow a consistent structure.
Basic Structure#
class MyPage extends HookWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => MyBloc()..add(const MyEvent.started()),
child: Scaffold(
headers: [
AppBar(
title: Text('페이지 제목'),
leading: [
Button(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const Icon(LucideIcons.arrowLeft),
),
],
trailing: [
Button(
variant: .ghost,
shape: .square,
onPressed: onSettings,
child: const Icon(LucideIcons.settings),
),
],
),
],
child: const _Body(),
),
);
}
}
class _Body extends StatelessWidget {
const _Body();
@override
Widget build(BuildContext context) {
return BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
children: [
// 컨텐츠
],
),
);
},
);
}
}
Pattern Selection Criteria#
| Pattern | When to Use | Example |
|---|---|---|
| HookWidget + BlocSignalProvider | Async data loading | Details Page, List |
| HookWidget only | Only local state needed | Settings Page, Form |
| StatelessWidget | No state | Static info Page |
Precautions#
// ✅ CORRECT: BlocSignalProvider는 페이지 레벨에서 생성
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => MyBloc(),
child: _Body(),
);
}
}
// ❌ WRONG: build 메서드 내에서 매번 생성 금지
class MyPage extends HookWidget {
@override
Widget build(BuildContext context) {
final bloc = MyBloc(); // 매번 새로 생성됨!
return BlocSignalProvider.value(
value: bloc,
child: _Body(),
);
}
}
2. State Management#
BLoC Pattern Overview#
User Action → Event → BLoC → State → UI Update
BlocSignalBuilder#
Used for UI rendering based on state.
BlocSignalBuilder<MyBloc, MyState>(
// Optional: specify rebuild conditions
buildWhen: (previous, current) => previous.items != current.items,
builder: (context, state) {
// State branching with switch expression (recommended)
return switch (state) {
MyStateInitial() => const SizedBox.shrink(),
MyStateLoading() => const Center(child: Loading()),
MyStateSuccess(:final data) => SuccessWidget(data: data),
// 실패는 EmptyState(tone: failure) — Flutter 의 ErrorWidget 과 이름을 겹치지 않는다
MyStateFailure(:final error) => EmptyState(
tone: CoreEmptyStateTone.failure,
title: Text(error),
),
};
},
)
BlocSignalConsumer#
Used when both UI rendering and side effects are needed.
BlocSignalConsumer<AuthBloc, AuthState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
// Side effects: navigation, snackbar, dialog
if (state.isAuthenticated) {
context.go('/home');
}
if (state.hasError) {
// Snackbar 대신 CoUI toast — CoUIApp 이 ToastLayer 를 이미 깐다
showToast(
context: context,
builder: (context, overlay) => Toast(
variant: CoreToastVariant.destructive,
message: state.errorMessage,
onDismiss: overlay.close,
),
);
}
},
buildWhen: (previous, current) => previous.formState != current.formState,
builder: (context, state) {
return LoginForm(
isLoading: state.isLoading,
isValid: state.isValid,
);
},
)
BlocSignalListener#
Used when only side effects are needed.
BlocSignalListener<PaymentBloc, PaymentState>(
listenWhen: (previous, current) => current.isCompleted,
listener: (context, state) {
showDialog(
context: context,
builder: (_) => SuccessDialog(),
);
},
child: PaymentForm(),
)
MultiBlocSignalProvider#
Used in pages requiring multiple BLoCs.
MultiBlocSignalProvider(
providers: [
BlocSignalProvider(create: (_) => UserBloc()),
BlocSignalProvider(create: (_) => SettingsBloc()),
BlocSignalProvider(create: (_) => NotificationBloc()),
],
child: SettingsPage(),
)
Event Dispatching#
// ✅ CORRECT: context.read 사용
Button(
onPressed: () {
context.read<MyBloc>().add(const MyEvent.submitted());
},
child: const Text('제출'),
)
// ❌ WRONG: context.watch로 이벤트 발송 금지
Button(
onPressed: () {
context.watch<MyBloc>().add(...); // 불필요한 리빌드 발생
},
)
3. Local State (Hooks)#
Basic Hooks#
class MyWidget extends HookWidget {
@override
Widget build(BuildContext context) {
// useState - 단순 상태
final counter = useState(0);
final isVisible = useState(true);
// useTextEditingController - 텍스트 입력
final controller = useTextEditingController();
// useEffect - 사이드 이펙트
useEffect(() {
final subscription = stream.listen((value) {
counter.value = value;
});
return subscription.cancel; // cleanup
}, []); // 빈 배열 = componentDidMount
return Column(
children: [
Text('Count: ${counter.value}'),
TextField(controller: controller),
Toggle(
value: isVisible.value,
onChanged: (v) => isVisible.value = v,
),
],
);
}
}
Animation Hooks#
길이는 CoreDuration 별칭, 곡선은 CoreEasing 별칭이다(원시 Duration(milliseconds: 300)
· Curves.easeInOut 가 아니다). resolveMotionDuration 이 동작 줄이기에서 전환을 0 으로 접는다 — SKILL 의 시각 다듬기 체크리스트 P4.
class FadeInContent extends HookWidget {
const FadeInContent({super.key});
@override
Widget build(BuildContext context) {
// 길이는 별칭, 동작 줄이기에서는 0 — resolveMotionDuration 이 접는다
final controller = useAnimationController(
duration: resolveMotionDuration(context, const Duration(milliseconds: CoreDuration.moderate)),
);
// 곡선도 별칭 — 들어오는 요소는 enter. 빌드마다 새로 만들지 않게 memoize 한다
final opacity = useMemoized(
() => CurvedAnimation(parent: controller, curve: CoreEasing.enter.toValue()),
[controller],
);
useEffect(() {
controller.forward();
return null;
}, const []);
return FadeTransition(opacity: opacity, child: const Content());
}
}
useAnimation(animation)은 애니메이션이 아니라 현재 값(double)을 돌려준다 —FadeTransition(opacity:)에는 애니메이션 자체를 넘긴다.
useMemoized#
Caches expensive computation results.
final expensiveResult = useMemoized(
() => computeExpensiveValue(items),
[items], // 의존성 변경 시 재계산
);
useCallback#
Memoizes callback functions.
final onSubmit = useCallback(() {
context.read<MyBloc>().add(const MyEvent.submitted());
}, []);
4. Typography#
Role chains#
Text picks a role through the Text chain — size, weight, line height and
tracking come as one set, and the Figma text style name is the role
(Title/Medium → .titleMedium).
Text('제목').titleMedium
Text('부제목').titleSmall
Text('본문').bodyMedium
Text('캡션').bodySmall
Text('강조 라벨').labelLarge.semiBold // 역할 위에 굵기만
| Figma 텍스트 스타일 | Text 체인 |
크기 | 굵기 | 행간 | 자간 | 서체 | v2 이전 |
|---|---|---|---|---|---|---|---|
Display/Large |
.displayLarge |
52 | 700 | 140% | −2% | 본문 | 같은 이름 |
Display/Medium |
.displayMedium |
48 | 700 | 140% | −2% | 본문 | 같은 이름 |
Display/Small |
.displaySmall |
44 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Large |
.headlineLarge |
32 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Medium |
.headlineMedium |
28 | 700 | 140% | −2% | 본문 | 같은 이름 |
Headline/Small |
.headlineSmall |
24 | 700 | 140% | −2% | 본문 | 같은 이름 |
Title/Large |
.titleLarge |
20 | 700 | 140% | −2% | 본문 | 같은 이름 |
Title/Medium |
.titleMedium |
18 | 700 | 140% | −1% | 본문 | 같은 이름 |
Title/Small |
.titleSmall |
16 | 700 | 140% | −1% | 본문 | 같은 이름 |
Body/Large |
.bodyLarge |
15 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/Medium |
.bodyMedium |
14 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/Small |
.bodySmall |
13 | 400 | 150% | −1% | 본문 | 같은 이름 |
Body/XS |
.bodyXs (v2부터) |
12 | 400 | 150% | −1% | 본문 | .bodySmall |
Label/XL2 |
.labelXl2 |
16 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/XL |
.labelXl |
15 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Large |
.labelLarge |
14 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Medium |
.labelMedium |
13 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/Small |
.labelSmall |
12 | 500 | 140% | −1% | 본문 | 같은 이름 |
Label/XS |
.labelXs (v2부터) |
11 | 500 | 140% | −1% | 본문 | .labelSmall |
Code/XS |
.codeXs (v2부터) |
11 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
Code/SM |
.codeSm (v2부터) |
12 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
Code/MD |
.codeMd (v2부터) |
13 | 400 | 21px 고정 | 0 | mono | .bodySmall.mono |
v2 이전— v2(coui_flutter 0.163 · coui_core 0.162) 전 릴리스에서 같은 자리에 쓰는 체인. 이름이 같아도 값은 대부분 v2 에서 바뀌었다 — 본문이 한 단계 작아지고(bodyMedium16 → 14), 헤드라인이 700 이 되고, 타이틀 · 라벨 행간이 140% 가 됐다. 그 전 릴리스의 값은 그 릴리스의CoreTypography.coui가 정한다 — 이 표의 숫자를 옮겨 쓰지 않는다.
Color on top of a role#
Chain a colour token after the role — it resolves against the current theme.
Text('제목').titleMedium.onSurface
Text('캡션').bodySmall.onSurfaceVariant
Text('저장하지 못했다').bodySmall.onErrorContainer
When a widget needs a TextStyle (a third-party widget, text you paint
yourself), read the same role and colour from the theme:
final style = context.typography.bodySmall.toValue(
theme: context.theme,
color: context.colorScheme.onSurfaceVariant.toValue(),
);
Precautions#
// ❌ WRONG: TextStyle 직접 정의 금지
Text('텍스트', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))
// ❌ WRONG: 없는 API
Text('텍스트', style: context.textStyles.lgSemibold) // context.textStyles 는 CoUI 에 없다
Text('텍스트').lg.bold // Tailwind 크기 체인은 제거됨
// ✅ CORRECT: 역할 체인
Text('텍스트').titleMedium
5. Buttons#
Button Variants (Button)#
A single Button widget selects its look via the variant: enum. Pass the
variant with dot-shorthand (variant: .ghost). Default is
variant: .primary, size: .md.
// Primary - 주요 액션 (제출, 확인) — variant defaults to primary
Button(
onPressed: onSubmit,
expanded: true, // 전체 너비
enabled: isValid, // 활성화 상태
child: const Text('확인'),
)
// Ghost - 보조 액션 (취소, 뒤로)
Button(
variant: .ghost,
onPressed: onCancel,
child: const Text('취소'),
)
// 카드 형태로 누르는 면 — Button 이 아니라 onTap 을 받은 Card (Button 의 .card 변형은 0.144 에서 제거)
Card(
onTap: onTap,
child: const Column(
children: [
Icon(LucideIcons.settings),
Text('설정'),
],
),
)
// Outline - 테두리 버튼
Button(
variant: .outline,
onPressed: onTap,
child: const Text('자세히 보기'),
)
// Destructive - 위험 액션
Button(
variant: .destructive,
onPressed: onDelete,
child: const Text('삭제'),
)
CoreButtonVariant: primary · secondary · outline ·
ghost · link ·
text · plain · destructive · card
· menu · menubar · fixed
Icon-only Buttons#
There is no separate IconButton. Use Button with shape: .square
(or
.circle) and a Icon child.
Button(
variant: .ghost,
shape: .square,
onPressed: onEdit,
child: const Icon(LucideIcons.pencil),
)
Button(
shape: .circle,
onPressed: onAdd,
child: const Icon(LucideIcons.plus),
)
LucideIconsonly covers the standard icon set. For a brand/custom icon with no Lucide equivalent, use the project's generatedAssets.svg.*accessor instead — see SKILL.md § 5.5 Icons.
Button Size#
Size is the size: enum (CoreComponentSize): xs · sm
· md (default) ·
lg · xl. There is no ButtonSize / ButtonDensity.
Button(
size: .sm,
onPressed: onTap,
child: const Text('작은 버튼'),
)
Precautions#
// ❌ WRONG: 제거된 named-constructor / ButtonStyle 패턴
Button.primary(onPressed: onTap, child: Text('확인'))
ButtonStyle.ghost()
Button.ghost(style: const ButtonStyle.ghost(), ...)
// ✅ CORRECT: Button + variant enum, chrome 은 buttonStyle 로
Button(variant: .ghost, onPressed: onTap, child: const Text('취소'))
Button(
buttonStyle: CoreButtonStyle(width: 200),
onPressed: onTap,
child: const Text('고정폭'),
)
6. Forms#
Basic Form Structure#
class LoginForm extends HookWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context) {
final emailController = useTextEditingController();
final passwordController = useTextEditingController();
// Errors live in state and show through FormField.error
final emailError = useState<String?>(null);
final passwordError = useState<String?>(null);
bool validate() {
final email = emailController.text;
emailError.value = email.isEmpty
? '이메일을 입력하세요'
: !email.contains('@')
? '올바른 이메일 형식이 아닙니다'
: null;
passwordError.value =
passwordController.text.length < 8 ? '8자 이상 입력하세요' : null;
return emailError.value == null && passwordError.value == null;
}
return Form(
rowSpacing: CoreSpace.space16,
children: [
FormField(
label: '이메일',
required: true,
error: emailError.value,
child: TextField(
controller: emailController,
type: CoreInputType.email,
placeholder: const Text('이메일을 입력하세요'),
),
),
FormField(
label: '비밀번호',
required: true,
error: passwordError.value,
child: TextField(
controller: passwordController,
type: CoreInputType.password,
placeholder: const Text('비밀번호를 입력하세요'),
),
),
Button(
expanded: true,
onPressed: () {
if (validate()) {
// 제출 로직
}
},
child: const Text('로그인'),
),
],
);
}
}
CoUI
Formlays the rows out (rowSpacing) and does not validate — keep the errors in state and show them throughFormField.error, as above. ForValidatorandFormController, see thecoui-formskill.FormTextFielddoes not exist. MaterialForm/TextFormFieldshare names with the CoUI barrel — if you really need them, importpackage:flutter/material.dartwith a prefix (as material).
TextField Properties#
// TextField — one-line input (the bare `Input` was removed in 0.127.16)
TextField(
type: CoreInputType.text, // text/password/email/number/tel/url/search
placeholder: const Text('플레이스홀더'), // a widget, not a String
value: value,
enabled: true,
required: false,
minLength: 3,
maxLength: 50,
onChanged: (value) {},
onSubmitted: (value) {},
)
// Error state + chrome overrides go through the textFieldStyle slot
TextField(
type: CoreInputType.email,
placeholder: const Text('이메일'),
errorText: '올바른 이메일이 아니다',
textFieldStyle: const CoreTextFieldStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
),
)
// Label / prefix / suffix / errorText slots
TextField(
label: '이메일',
placeholder: const Text('이메일을 입력하세요'),
controller: controller,
keyboardType: TextInputType.emailAddress,
prefix: const Icon(LucideIcons.mail),
errorText: emailError, // null 이면 에러 미표시
onChanged: (value) {},
)
Validation State Visualization#
enum ValidationState { pending, valid, invalid }
Widget buildValidationIcon(BuildContext context, ValidationState state) {
final colorScheme = context.theme.colorScheme;
return switch (state) {
ValidationState.pending => Icon(
LucideIcons.circle,
iconStyle: CoreIconStyle(color: colorScheme.onSurface),
),
ValidationState.valid => Icon(
LucideIcons.circleCheck,
iconStyle: CoreIconStyle(color: colorScheme.success),
),
ValidationState.invalid => Icon(
LucideIcons.circleAlert,
iconStyle: CoreIconStyle(color: colorScheme.error),
),
};
}
7. Loading States#
Skeletons in a loading region#
The caller places Skeletons shaped like the final layout and wraps the
area in a LoadingRegion — the region announces "loading" once. Only the caller
knows the final shape, so no widget converts itself into a skeleton.
BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
if (state.isLoading) {
return LoadingRegion(
semanticLabel: '카드 불러오는 중',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Skeleton(width: 200, height: 20),
Skeleton(width: 150, height: 16),
],
),
);
}
return MyCard(title: state.title, subtitle: state.subtitle);
},
)
In-progress spinner#
const Loading()
Empty and failed areas — EmptyState#
items.isEmpty
? EmptyState(
icon: const Icon(LucideIcons.inbox),
title: const Text('항목이 없습니다'),
description: const Text('새 항목을 추가해보세요'),
action: Button(onPressed: onCreate, child: const Text('추가')),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
)
State-based UI Branching#
BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
return switch (state.status) {
LoadingStatus.initial => const SizedBox.shrink(),
LoadingStatus.loading => const Center(child: Loading()),
LoadingStatus.success => SuccessWidget(data: state.data),
LoadingStatus.failure => EmptyState(
tone: CoreEmptyStateTone.failure,
title: Text(state.errorMessage ?? '불러오지 못했다'),
action: Button(
onPressed: () => context.read<MyBloc>().add(const MyEvent.retried()),
child: const Text('다시 시도'),
),
),
};
},
)
❌
.skeletonizer()·.asSkeleton(0.156 deprecated) ·loadingOr·emptyOrWhen· MaterialCircularProgressIndicator·EmptyStateWidget.
8. Color System#
ColorScheme Access#
final colorScheme = context.colorScheme; // slots are CoreColor — .toValue() for a Flutter Color
CoUI's current ColorScheme (a CoreColorScheme typedef) uses Material 3
semantic tokens — the old daisyUI-style names below are deprecated / no
longer the recommended API. Verify the full token list against coui-theme.
Semantic Colors (M3 tokens — current)#
| Token | Purpose | Example |
|---|---|---|
primary / onPrimary |
Primary actions, brand / text-on-primary | Buttons, links |
secondary / onSecondary |
Secondary emphasis | Secondary buttons, chips |
success | Success state | Completion, approval |
error / onError |
Error state / text-on-error | Failure, destructive actions |
warning | Warning state | Caution, alerts |
surface | Default background | Page background |
surfaceContainer |
Card / elevated-surface background | Cards, containers |
surfaceContainerHigh |
Higher-elevation background / strong border | Emphasized panels, dark dividers |
onSurface |
Default text on surface |
Titles, body text |
onSurfaceVariant | Secondary/muted text | Captions, placeholders |
outlineVariant |
Default border/divider | Dividers, hairline borders |
❌ Deprecated (daisyUI-era, do not use in new code)#
primaryContent, base100, base200, base300, baseContent,
successContent,
and the Text('...').baseContent / .successContent chains — none of these are the
current API. Use the M3 tokens above: on text through the Text colour chains
(Text('…').onSurface), in a component through its style slot.
Usage Examples#
Card(
cardStyle: const CoreCardStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
),
child: Column(
children: [
Text('카드 내용').bodyMedium.onSurface,
const Divider(
dividerStyle: CoreDividerStyle(color: CoreColor.token(CoreColors.outlineVariant)),
),
Text('성공!').bodyMedium.onSuccessContainer,
],
),
)
Applying Opacity#
// ✅ CoreColor (keeps the token) — for a CoUI style slot
colorScheme.onSurface.withOpacity(0.5)
// ✅ Flutter Color — for a Flutter API
colorScheme.onSurface.toValue().withValues(alpha: 0.5)
// ❌ WRONG: Flutter Color.withOpacity (deprecated)
Colors.black.withOpacity(0.2)
9. Spacing#
Gap Widget#
Gap.spaceN() token constructors — the token name equals its pixel value.
For non-token sizes / cross extent / fill color use gapStyle: CoreGapStyle(...).
const Gap.space4() // 4px
const Gap.space8() // 8px
const Gap.space12() // 12px
const Gap.space16() // 16px
const Gap.space20() // 20px
const Gap.space24() // 24px
const Gap.space32() // 32px
// 비토큰 크기 / cross extent / fill color
const Gap(gapStyle: CoreGapStyle(size: 18))
Removed/legacy:
Gap.s1(),Gap(Spacing.s4),Spacing.s*,Insets.*,Gap(size: CoreSpace.spaceN)(v0.92 → useGap.spaceN()).
CoreSpace in EdgeInsets#
const EdgeInsets.all(CoreSpace.space16)
const EdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space8,
)
const EdgeInsets.only(
top: CoreSpace.space16,
bottom: CoreSpace.space8,
left: CoreSpace.space16,
right: CoreSpace.space16,
)
Consistent Spacing Usage#
Column(
children: [
MyHeaderSection(),
const Gap.space16(), // 헤더 아래 16px
MyContentSection(),
const Gap.space24(), // 섹션 간 24px
MyFooterSection(), // CoUI 에 `Footer` 위젯이 있다 — 앱 위젯은 겹치지 않는 이름으로
],
)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: CoreSpace.space16, // 좌우 16px
vertical: CoreSpace.space8, // 상하 8px
),
child: Content(),
)
10. Overlays#
Dialog#
Future<bool?> showConfirmDialog(BuildContext context) {
// CoUI showDialog + Dialog — the barrier and motion come from the dialog style
return showDialog<bool>(
context: context,
builder: (context) => Dialog(
title: const Text('삭제 확인'),
content: const Text('정말 삭제하시겠습니까?'),
actions: [
Button(
variant: .ghost,
onPressed: () => Navigator.pop(context, false),
child: const Text('취소'),
),
Button(
variant: .destructive,
onPressed: () => Navigator.pop(context, true),
child: const Text('삭제'),
),
],
),
);
}
// 사용
final result = await showConfirmDialog(context);
if (result == true) {
// 삭제 수행
}
BottomSheet#
// A bottom sheet is a Drawer docked at the bottom — the panel, its corners,
// the safe area and drag-to-dismiss come from the drawer. Rows are MenuItems.
void showOptionsSheet(BuildContext context) {
openDrawer<void>(
context: context,
side: CoreDrawerSide.bottom,
builder: (sheetContext) => Drawer(
side: CoreDrawerSide.bottom,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
MenuItem(
leading: const Icon(LucideIcons.pencil),
label: const Text('편집'),
onPressed: () {
closeDrawer<void>(sheetContext);
// 편집 로직
},
),
MenuItem(
tone: CoreMenuItemTone.critical,
leading: const Icon(LucideIcons.trash),
label: const Text('삭제'),
onPressed: () {
closeDrawer<void>(sheetContext);
// 삭제 로직
},
),
],
),
),
);
}
Popover#
A "⋯" overflow menu is a DropdownMenu — the panel and its rows are drawn by
the menu, not by the caller.
DropdownMenu(
trigger: Button(
variant: .ghost,
onPressed: () {},
child: const Icon(LucideIcons.ellipsisVertical),
),
items: [
CoreMenuItem.action('옵션 1', onPressed: onOption1),
CoreMenuItem.action('옵션 2', onPressed: onOption2),
],
)
Arbitrary content in a floating panel is a Popover — the panel box is the
popover's, styled through popoverStyle.panelStyle.
Popover(
trigger: Button(onPressed: () {}, child: const Text('필터')),
content: const FilterPanel(),
)
11. Lists#
Basic ListView#
ListView.separated(
padding: const EdgeInsets.all(CoreSpace.space16),
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.space8(),
itemBuilder: (context, index) {
final item = items[index];
return ItemCard(item: item);
},
)
Pull to refresh — RefreshTrigger#
// Material RefreshIndicator 가 아니라 CoUI RefreshTrigger
RefreshTrigger(
onRefresh: () async {
context.read<MyBloc>().add(const MyEvent.refreshed());
// BLoC 상태가 업데이트될 때까지 대기
await context.read<MyBloc>().stream.firstWhere(
(state) => !state.isRefreshing,
);
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
),
)
CustomScrollView + SliverList#
CustomScrollView(
slivers: [
SliverAppBar(
title: Text('목록'),
floating: true,
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
sliver: SliverList.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Gap.space8(),
itemBuilder: (context, index) => ItemCard(item: items[index]),
),
),
],
)
Infinite Scroll (Pagination)#
class InfiniteListWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final scrollController = useScrollController();
useEffect(() {
void onScroll() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
context.read<MyBloc>().add(const MyEvent.loadMore());
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, []);
return BlocSignalBuilder<MyBloc, MyState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(child: Loading());
}
return ItemCard(item: state.items[index]);
},
);
},
);
}
}
12. Navigation#
GoRouter Setup#
// route 정의
@TypedGoRoute<HomeRoute>(
path: '/',
routes: [
TypedGoRoute<ProfileRoute>(path: 'profile/:userId'),
TypedGoRoute<SettingsRoute>(path: 'settings'),
],
)
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HomePage();
}
}
class ProfileRoute extends GoRouteData {
const ProfileRoute({required this.userId});
final String userId;
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfilePage(userId: userId);
}
}
Navigation Methods#
// Navigate (replace history)
context.go('/home');
// Push page (keep history)
context.push('/details');
// Go back
context.pop();
// Go back with result
context.pop(result);
// TypedRoute usage (type-safe)
const ProfileRoute(userId: '123').go(context);
const ProfileRoute(userId: '123').push(context);
Receiving Navigation Results#
// Navigating page
Button(
onPressed: () async {
final result = await context.push<String>('/select-item');
if (result != null) {
// 결과 처리
}
},
child: const Text('선택'),
)
// Page returning result
Button(
onPressed: () => context.pop(selectedItem),
child: const Text('확인'),
)
Deep Link Parameters#
@TypedGoRoute<SearchRoute>(path: '/search')
class SearchRoute extends GoRouteData {
const SearchRoute({this.query, this.category});
final String? query;
final String? category;
@override
Widget build(BuildContext context, GoRouterState state) {
return SearchPage(
initialQuery: query,
initialCategory: category,
);
}
}
// Navigate with query parameters
const SearchRoute(query: 'flutter', category: 'tutorial').go(context);
// → /search?query=flutter&category=tutorial