문서 목차
이 페이지에서목차

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#

PatternWhen to UseExample
HookWidget + BlocSignalProviderAsync data loadingDetails Page, List
HookWidget onlyOnly local state neededSettings Page, Form
StatelessWidgetNo stateStatic 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 에서 바뀌었다 — 본문이 한 단계 작아지고(bodyMedium 16 → 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),
)

LucideIcons only covers the standard icon set. For a brand/custom icon with no Lucide equivalent, use the project's generated Assets.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 Form lays the rows out (rowSpacing) and does not validate — keep the errors in state and show them through FormField.error, as above. For Validator and FormController, see the coui-form skill. FormTextField does not exist. Material Form / TextFormField share names with the CoUI barrel — if you really need them, import package:flutter/material.dart with 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 · Material CircularProgressIndicator · 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)#

TokenPurposeExample
primary / onPrimary Primary actions, brand / text-on-primary Buttons, links
secondary / onSecondary Secondary emphasis Secondary buttons, chips
successSuccess stateCompletion, approval
error / onError Error state / text-on-error Failure, destructive actions
warningWarning stateCaution, alerts
surfaceDefault backgroundPage 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
onSurfaceVariantSecondary/muted textCaptions, 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 → use Gap.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);
  }
}
// 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('확인'),
)
@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

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기