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

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 the Co prefix, no Co<X> alias remains. Spacing uses Gap.spaceN() token constructors, and text picks a role through the Text chain (Text('…').titleMedium). Import CoUI types through package: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#

  1. Select the appropriate template.
  2. Find [TODO] comments and customize for your project.
  3. Change types, names, and logic.
  4. Delete unnecessary parts.

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

↑ ↓ 이동 · Enter 열기Esc 닫기