LogoSkills

figma-to-coui Reference

Provides detailed descriptions, example code, and precautions for each Use Case.

Flutter UI Patterns - Reference#

๊ธฐ์ค€: coui v0.97.15 (latest). kobic vendored๋Š” v0.92.0์ด๋ผ gap/input ๋“ฑ ์ผ๋ถ€ API๊ฐ€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Œ.

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 BlocProvider(
      create: (_) => MyBloc()..add(const MyEvent.started()),
      child: Scaffold(
        headers: [
          AppBar(
            title: Text('ํŽ˜์ด์ง€ ์ œ๋ชฉ'),
            leading: [
              CoButton(
                variant: .ghost,
                shape: .square,
                onPressed: () => context.pop(),
                child: const CoIcon(CoLucideIcons.arrowLeft),
              ),
            ],
            trailing: [
              CoButton(
                variant: .ghost,
                shape: .square,
                onPressed: onSettings,
                child: const CoIcon(CoLucideIcons.settings),
              ),
            ],
          ),
        ],
        child: const _Body(),
      ),
    );
  }
}

class _Body extends StatelessWidget {
  const _Body();

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<MyBloc, MyState>(
      builder: (context, state) {
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
          child: Column(
            children: [
              // ์ปจํ…์ธ 
            ],
          ),
        );
      },
    );
  }
}

Pattern Selection Criteria#

PatternWhen to UseExample
HookWidget + BlocProviderAsync data loadingDetails Page, List
HookWidget onlyOnly local state neededSettings Page, Form
StatelessWidgetNo stateStatic info Page

Precautions#

// โœ… CORRECT: BlocProvider๋Š” ํŽ˜์ด์ง€ ๋ ˆ๋ฒจ์—์„œ ์ƒ์„ฑ
class MyPage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => MyBloc(),
      child: _Body(),
    );
  }
}

// โŒ WRONG: build ๋ฉ”์„œ๋“œ ๋‚ด์—์„œ ๋งค๋ฒˆ ์ƒ์„ฑ ๊ธˆ์ง€
class MyPage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final bloc = MyBloc(); // ๋งค๋ฒˆ ์ƒˆ๋กœ ์ƒ์„ฑ๋จ!
    return BlocProvider.value(
      value: bloc,
      child: _Body(),
    );
  }
}

2. State Management#

BLoC Pattern Overview#

User Action โ†’ Event โ†’ BLoC โ†’ State โ†’ UI Update

BlocBuilder#

Used for UI rendering based on state.

BlocBuilder<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 LoadingIndicator(),
      MyStateSuccess(:final data) => SuccessWidget(data: data),
      MyStateFailure(:final error) => ErrorWidget(message: error),
    };
  },
)

BlocConsumer#

Used when both UI rendering and side effects are needed.

BlocConsumer<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) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.errorMessage)),
      );
    }
  },
  buildWhen: (previous, current) => previous.formState != current.formState,
  builder: (context, state) {
    return LoginForm(
      isLoading: state.isLoading,
      isValid: state.isValid,
    );
  },
)

BlocListener#

Used when only side effects are needed.

BlocListener<PaymentBloc, PaymentState>(
  listenWhen: (previous, current) => current.isCompleted,
  listener: (context, state) {
    showDialog(
      context: context,
      builder: (_) => SuccessDialog(),
    );
  },
  child: PaymentForm(),
)

MultiBlocProvider#

Used in pages requiring multiple BLoCs.

MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => UserBloc()),
    BlocProvider(create: (_) => SettingsBloc()),
    BlocProvider(create: (_) => NotificationBloc()),
  ],
  child: SettingsPage(),
)

Event Dispatching#

// โœ… CORRECT: context.read ์‚ฌ์šฉ
CoButton(
  onPressed: () {
    context.read<MyBloc>().add(const MyEvent.submitted());
  },
  child: const Text('์ œ์ถœ'),
)

// โŒ WRONG: context.watch๋กœ ์ด๋ฒคํŠธ ๋ฐœ์†ก ๊ธˆ์ง€
CoButton(
  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}'),
        CoInput(controller: controller),
        Switch(
          value: isVisible.value,
          onChanged: (v) => isVisible.value = v,
        ),
      ],
    );
  }
}

Animation Hooks#

class AnimatedWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final animationController = useAnimationController(
      duration: const Duration(milliseconds: 300),
    );

    final animation = useAnimation(
      CurvedAnimation(
        parent: animationController,
        curve: Curves.easeInOut,
      ),
    );

    return FadeTransition(
      opacity: animation,
      child: Content(),
    );
  }
}

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#

context.textStyles Pattern#

This project's standard text styling approach. context.textStyles returns the CoUI Typography from the current theme; pick a <size><weight> getter and pass it to Text(..., style:).

// ํฌ๊ธฐ + ๊ตต๊ธฐ ๊ฒฐํ•ฉ getter (์ด๋ฆ„์ด ๊ณง ์Šคํƒ€์ผ)
Text('ํ…์ŠคํŠธ', style: context.textStyles.xsSemibold)   // 12px / 600
Text('ํ…์ŠคํŠธ', style: context.textStyles.smNormal)     // 14px / 400
Text('ํ…์ŠคํŠธ', style: context.textStyles.baseNormal)   // 16px / 400
Text('ํ…์ŠคํŠธ', style: context.textStyles.lgSemibold)   // 18px / 600
Text('ํ…์ŠคํŠธ', style: context.textStyles.xlBold)       // 20px / 700

// ์ž์ฃผ ์“ฐ๋Š” ์กฐํ•ฉ
Text('์ œ๋ชฉ', style: context.textStyles.lgSemibold)
Text('๋ถ€์ œ๋ชฉ', style: context.textStyles.baseSemibold)
Text('๋ณธ๋ฌธ', style: context.textStyles.smNormal)

Color Composition (copyWith)#

Apply color via copyWith on the style getter โ€” there is no color chaining.

Text(
  '์ œ๋ชฉ',
  style: context.textStyles.lgSemibold.copyWith(
    color: context.appColors.neutral5,
  ),
)

Text(
  '์บก์…˜',
  style: context.textStyles.smNormal.copyWith(
    color: context.appColors.neutral60,
  ),
)

Precautions#

// โŒ WRONG: TextStyle ์ง์ ‘ ์ •์˜ ๊ธˆ์ง€
Text('ํ…์ŠคํŠธ', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))

// โŒ WRONG: ์ œ๊ฑฐ๋œ ํŒจํ„ด
Text('ํ…์ŠคํŠธ').lg.bold                    // chaining ์ œ๊ฑฐ๋จ
context.theme.typography.lg              // context.theme.typography ๊ธˆ์ง€

// โœ… CORRECT: context.textStyles getter ์‚ฌ์šฉ
Text('ํ…์ŠคํŠธ', style: context.textStyles.lgSemibold)

5. Buttons#

Button Variants (CoButton)#

A single CoButton 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
CoButton(
  onPressed: onSubmit,
  expanded: true,        // ์ „์ฒด ๋„ˆ๋น„
  enabled: isValid,      // ํ™œ์„ฑํ™” ์ƒํƒœ
  child: const Text('ํ™•์ธ'),
)

// Ghost - ๋ณด์กฐ ์•ก์…˜ (์ทจ์†Œ, ๋’ค๋กœ)
CoButton(
  variant: .ghost,
  onPressed: onCancel,
  child: const Text('์ทจ์†Œ'),
)

// Card - ์นด๋“œ ํ˜•ํƒœ ๋ฒ„ํŠผ
CoButton(
  variant: .card,
  onPressed: onTap,
  child: const Column(
    children: [
      CoIcon(CoLucideIcons.settings),
      Text('์„ค์ •'),
    ],
  ),
)

// Outline - ํ…Œ๋‘๋ฆฌ ๋ฒ„ํŠผ
CoButton(
  variant: .outline,
  onPressed: onTap,
  child: const Text('์ž์„ธํžˆ ๋ณด๊ธฐ'),
)

// Destructive - ์œ„ํ—˜ ์•ก์…˜
CoButton(
  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 CoButton with shape: .square (or .circle) and a CoIcon child.

CoButton(
  variant: .ghost,
  shape: .square,
  onPressed: onEdit,
  child: const CoIcon(CoLucideIcons.pencil),
)

CoButton(
  shape: .circle,
  onPressed: onAdd,
  child: const CoIcon(CoLucideIcons.plus),
)

CoLucideIcons 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.

CoButton(
  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: CoButton + variant enum, chrome ์€ buttonStyle ๋กœ
CoButton(variant: .ghost, onPressed: onTap, child: const Text('์ทจ์†Œ'))
CoButton(
  buttonStyle: CoreButtonStyle(width: 200),
  onPressed: onTap,
  child: const Text('๊ณ ์ •ํญ'),
)

6. Forms#

Basic Form Structure#

class LoginForm extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final formKey = useMemoized(GlobalKey<FormState>.new);
    final emailController = useTextEditingController();
    final passwordController = useTextEditingController();
    final isValid = useState(false);

    void validate() {
      isValid.value = formKey.currentState?.validate() ?? false;
    }

    return Form(
      key: formKey,
      onChanged: validate,
      child: Column(
        children: [
          TextFormField(
            controller: emailController,
            decoration: const InputDecoration(
              labelText: '์ด๋ฉ”์ผ',
              hintText: '์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”',
            ),
            keyboardType: TextInputType.emailAddress,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”';
              }
              if (!value.contains('@')) {
                return '์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค';
              }
              return null;
            },
          ),
          const CoGap.space16(),
          TextFormField(
            controller: passwordController,
            decoration: const InputDecoration(
              labelText: '๋น„๋ฐ€๋ฒˆํ˜ธ',
              hintText: '๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”',
            ),
            obscureText: true,
            validator: (value) {
              if (value == null || value.length < 8) {
                return '8์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”';
              }
              return null;
            },
          ),
          const CoGap.space24(),
          CoButton(
            expanded: true,
            enabled: isValid.value,
            onPressed: () {
              if (formKey.currentState!.validate()) {
                // ์ œ์ถœ ๋กœ์ง
              }
            },
            child: const Text('๋กœ๊ทธ์ธ'),
          ),
        ],
      ),
    );
  }
}

FormTextField does not exist. Use CoInput (bare text-entry primitive) or the CoUI CoTextField component (labelled field with slots). For Material-form validation you may still use Form + TextFormField.

CoInput / CoTextField Properties#

// CoInput - bare text-entry primitive (no label / leading / trailing slots)
CoInput(
  type: CoreInputType.text,    // text/password/email/number/tel/url/search
  placeholder: 'ํ”Œ๋ ˆ์ด์Šคํ™€๋”',
  value: value,
  enabled: true,
  required: false,
  minLength: 3,
  maxLength: 50,
  onChanged: (value) {},
  onSubmitted: (value) {},
)

// ์—๋Ÿฌ variant + chrome ์˜ค๋ฒ„๋ผ์ด๋“œ๋Š” inputStyle ์Šฌ๋กฏ (size:/shape: ํŒŒ๋ผ๋ฏธํ„ฐ ์—†์Œ)
CoInput(
  type: CoreInputType.email,
  placeholder: '์ด๋ฉ”์ผ',
  variant: CoreInputVariant.error,
  inputStyle: CoreInputStyle(
    borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
  ),
)

// CoTextField - label / placeholder / prefix / suffix / errorText ์Šฌ๋กฏ
CoTextField(
  label: '์ด๋ฉ”์ผ',
  placeholder: '์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”',
  controller: controller,
  keyboardType: TextInputType.emailAddress,
  prefix: const CoIcon(CoLucideIcons.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 => CoIcon(
        CoLucideIcons.circle,
        iconStyle: CoreIconStyle(color: colorScheme.baseContent.toValue()),
      ),
    ValidationState.valid => CoIcon(
        CoLucideIcons.circleCheck,
        iconStyle: CoreIconStyle(color: colorScheme.success.toValue()),
      ),
    ValidationState.invalid => CoIcon(
        CoLucideIcons.circleAlert,
        iconStyle: CoreIconStyle(color: colorScheme.error.toValue()),
      ),
  };
}

7. Loading States#

Skeletonizer#

Converts widget to skeleton loading state.

BlocBuilder<MyBloc, MyState>(
  builder: (context, state) {
    return MyCard(
      title: state.title ?? 'Loading Title',
      subtitle: state.subtitle ?? 'Loading Subtitle',
    ).skeletonizer(enabled: state.isLoading);
  },
)

loadingOr Extension#

Displays conditional loading widget.

// default ์‚ฌ์šฉ
ContentWidget().loadingOr(
  isLoading: isLoading,
  loadingWidget: const CircularProgressIndicator(),
)

// Mock ๋ฐ์ดํ„ฐ๋กœ ์Šค์ผˆ๋ ˆํ†ค
ContentWidget().loadingOrWithMock(
  isLoading: isLoading,
  mockWidget: () => ContentWidget(data: MockData()),
)

emptyOrWhen Extension#

Used for empty state handling.

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemCard(item: items[index]),
).emptyOrWhen(
  condition: () => items.isEmpty,
  emptyWidget: const EmptyStateWidget(
    icon: CoIcon(CoLucideIcons.inbox),
    title: 'ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค',
    subtitle: '์ƒˆ ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”',
  ),
)

State-based UI Branching#

BlocBuilder<MyBloc, MyState>(
  builder: (context, state) {
    return switch (state.status) {
      LoadingStatus.initial => const SizedBox.shrink(),
      LoadingStatus.loading => const Center(
          child: CircularProgressIndicator(),
        ),
      LoadingStatus.success => SuccessWidget(data: state.data),
      LoadingStatus.failure => ErrorWidget(
          message: state.errorMessage,
          onRetry: () => context.read<MyBloc>().add(const MyEvent.retried()),
        ),
    };
  },
)

8. Color System#

ColorScheme Access#

final colorScheme = context.theme.colorScheme;

Semantic Colors#

TokenPurposeExample
primaryPrimary actions, brandButtons, links
primaryContentText on primaryButton text
successSuccess stateCompletion, approval
errorError stateFailure, error
warningWarning stateCaution, alerts
infoInformation displayGuidance, tips

Base Colors#

TokenPurposeExample
base100Default backgroundPage background
base200Card backgroundCards, containers
base300Divider lines, bordersDividers
baseContentDefault textTitles, body text
neutralNeutral elementsDisabled buttons

Usage Examples#

Container(
  color: colorScheme.base100,
  child: Column(
    children: [
      Container(
        color: colorScheme.base200,
        child: Text('์นด๋“œ ๋‚ด์šฉ').baseContent,
      ),
      Divider(color: colorScheme.base300),
      Container(
        color: colorScheme.success,
        child: Text('์„ฑ๊ณต!').successContent,
      ),
    ],
  ),
)

Applying Opacity#

// โœ… CORRECT: withValues ์‚ฌ์šฉ
colorScheme.baseContent.withValues(alpha: 0.5)
Colors.black.withValues(alpha: 0.2)

// โŒ WRONG: withOpacity ์‚ฌ์šฉ (deprecated)
colorScheme.baseContent.withOpacity(0.5)

9. Spacing#

CoGap Widget#

CoGap.spaceN() token constructors โ€” the token name equals its pixel value. For non-token sizes / cross extent / fill color use gapStyle: CoreGapStyle(...).

const CoGap.space4()    // 4px
const CoGap.space8()    // 8px
const CoGap.space12()   // 12px
const CoGap.space16()   // 16px
const CoGap.space20()   // 20px
const CoGap.space24()   // 24px
const CoGap.space32()   // 32px

// ๋น„ํ† ํฐ ํฌ๊ธฐ / cross extent / fill color
const CoGap(gapStyle: CoreGapStyle(size: 18))

Removed/legacy: Gap.s1(), Gap(Spacing.s4), Spacing.s*, Insets.*, CoGap(size: CoreSpace.spaceN) (v0.92 โ†’ use CoGap.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: [
    Header(),
    const CoGap.space16(),   // ํ—ค๋” ์•„๋ž˜ 16px
    ContentSection(),
    const CoGap.space24(),   // ์„น์…˜ ๊ฐ„ 24px
    Footer(),
  ],
)

Padding(
  padding: const EdgeInsets.symmetric(
    horizontal: CoreSpace.space16,  // ์ขŒ์šฐ 16px
    vertical: CoreSpace.space8,     // ์ƒํ•˜ 8px
  ),
  child: Content(),
)

10. Overlays#

Dialog#

Future<bool?> showConfirmDialog(BuildContext context) {
  return showDialog<bool>(
    context: context,
    barrierColor: Colors.black.withValues(alpha: 0.2),
    builder: (context) => AlertDialog(
      title: const Text('์‚ญ์ œ ํ™•์ธ'),
      content: const Text('์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'),
      actions: [
        CoButton(
          variant: .ghost,
          onPressed: () => Navigator.pop(context, false),
          child: const Text('์ทจ์†Œ'),
        ),
        CoButton(
          variant: .destructive,
          onPressed: () => Navigator.pop(context, true),
          child: const Text('์‚ญ์ œ'),
        ),
      ],
    ),
  );
}

// ์‚ฌ์šฉ
final result = await showConfirmDialog(context);
if (result == true) {
  // ์‚ญ์ œ ์ˆ˜ํ–‰
}

BottomSheet#

void showOptionsSheet(BuildContext context) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,  // ๋†’์ด ์กฐ์ ˆ ๊ฐ€๋Šฅ
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
    ),
    builder: (context) => SafeArea(
      child: Padding(
        padding: const EdgeInsets.all(CoreSpace.space16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            ListTile(
              leading: const CoIcon(CoLucideIcons.pencil),
              title: const Text('ํŽธ์ง‘'),
              onTap: () {
                Navigator.pop(context);
                // ํŽธ์ง‘ ๋กœ์ง
              },
            ),
            ListTile(
              leading: const CoIcon(CoLucideIcons.trash),
              title: Text(
                '์‚ญ์ œ',
                style: context.textStyles.baseNormal.copyWith(
                  color: context.theme.colorScheme.error,
                ),
              ),
              onTap: () {
                Navigator.pop(context);
                // ์‚ญ์ œ ๋กœ์ง
              },
            ),
          ],
        ),
      ),
    ),
  );
}

Popover#

Popover(
  positions: [PopoverPosition.bottom],
  barrierColor: Colors.transparent,
  builder: (context) => Container(
    padding: const EdgeInsets.all(CoreSpace.space8),
    decoration: BoxDecoration(
      color: colorScheme.base200,
      borderRadius: BorderRadius.circular(8),
      boxShadow: [
        BoxShadow(
          color: Colors.black.withValues(alpha: 0.1),
          blurRadius: 8,
        ),
      ],
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        PopoverItem(title: '์˜ต์…˜ 1', onTap: onOption1),
        PopoverItem(title: '์˜ต์…˜ 2', onTap: onOption2),
      ],
    ),
  ),
  child: CoButton(
    variant: .ghost,
    shape: .square,
    onPressed: null, // Popover๊ฐ€ ์ฒ˜๋ฆฌ
    child: const CoIcon(CoLucideIcons.ellipsisVertical),
  ),
)

11. Lists#

Basic ListView#

ListView.separated(
  padding: const EdgeInsets.all(CoreSpace.space16),
  itemCount: items.length,
  separatorBuilder: (_, __) => const CoGap.space8(),
  itemBuilder: (context, index) {
    final item = items[index];
    return ItemCard(item: item);
  },
)

RefreshIndicator#

RefreshIndicator(
  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 CoGap.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 BlocBuilder<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: CircularProgressIndicator());
            }
            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
CoButton(
  onPressed: () async {
    final result = await context.push<String>('/select-item');
    if (result != null) {
      // ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ
    }
  },
  child: const Text('์„ ํƒ'),
)

// Page returning result
CoButton(
  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