Flutter UI Patterns - Templates#
๊ธฐ์ค: coui v0.97.15 (latest). kobic vendored๋ v0.92.0์ด๋ผ gap/input ๋ฑ ์ผ๋ถ API๊ฐ ๋ค๋ฅผ ์ ์์.
Ready-to-use code templates that can be copied directly (kobic Co
All components are
Co<X>(CoButton/CoBadge/CoIcon/CoGap/CoInput), spacing usesCoGap.spaceN()token constructors, and text styling usescontext.textStyles. Import CoUI types throughpackage:core/core.dart.
1. Basic Page Template#
BLoC Page#
import 'package:core/core.dart';
import 'package:dependencies/dependencies.dart';
/// [TODO] Page description
class MyPage extends HookWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
// [TODO] Change BLoC type
create: (_) => MyBloc()..add(const MyEvent.started()),
child: Scaffold(
headers: [
AppBar(
// [TODO] Change page title
title: Text('ํ์ด์ง ์ ๋ชฉ'),
leading: [
CoButton(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const CoIcon(CoLucideIcons.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 BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
// [TODO] UI branching by state
return switch (state.status) {
LoadingStatus.initial || LoadingStatus.loading =>
const Center(child: CircularProgressIndicator()),
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 CoGap.space16(),
// [TODO] Implement content
Text('์ปจํ
์ธ ', style: context.textStyles.lgSemibold),
],
),
);
}
}
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 CoIcon(
CoLucideIcons.circleAlert,
iconStyle: CoreIconStyle(size: 48),
),
const CoGap.space16(),
Text(message, style: context.textStyles.baseNormal),
const CoGap.space16(),
CoButton(
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: [
CoButton(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const CoIcon(CoLucideIcons.arrowLeft),
),
],
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Column(
children: [
const CoGap.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) {
final colorScheme = context.theme.colorScheme;
final formKey = useMemoized(GlobalKey<FormState>.new);
// [TODO] Define controllers
final nameController = useTextEditingController();
final emailController = useTextEditingController();
final isValid = useState(false);
final isLoading = useState(false);
void validate() {
isValid.value = formKey.currentState?.validate() ?? false;
}
Future<void> submit() async {
if (!formKey.currentState!.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: [
CoButton(
variant: .ghost,
shape: .square,
onPressed: () => context.pop(),
child: const CoIcon(CoLucideIcons.x),
),
],
),
],
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: CoreSpace.space16),
child: Form(
key: formKey,
onChanged: validate,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CoGap.space16(),
// [TODO] Define form fields
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: '์ด๋ฆ',
hintText: '์ด๋ฆ์ ์
๋ ฅํ์ธ์',
),
validator: (value) {
if (value == null || value.isEmpty) {
return '์ด๋ฆ์ ์
๋ ฅํ์ธ์';
}
return null;
},
),
const CoGap.space16(),
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 Spacer(),
// ์ ์ถ ๋ฒํผ
CoButton(
expanded: true,
enabled: isValid.value && !isLoading.value,
onPressed: submit,
child: isLoading.value
? const CircularProgressIndicator()
: const Text('์ ์ถ'),
),
const CoGap.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 BlocProvider(
create: (_) => MyListBloc()..add(const MyListEvent.started()),
child: Scaffold(
headers: [
AppBar(
title: Text('๋ชฉ๋ก'),
trailing: [
CoButton(
variant: .ghost,
shape: .square,
onPressed: () => context.push('/create'),
child: const CoIcon(CoLucideIcons.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 BlocBuilder<MyListBloc, MyListState>(
builder: (context, state) {
if (state.isInitialLoading) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
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 CoGap.space8(),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(CoreSpace.space16),
child: CircularProgressIndicator(),
),
);
}
return _ItemCard(item: state.items[index]);
},
).emptyOrWhen(
condition: () => state.items.isEmpty,
emptyWidget: const _EmptyView(),
),
);
},
);
}
}
class _ItemCard extends StatelessWidget {
const _ItemCard({required this.item});
// [TODO] Change item type
final MyItem item;
@override
Widget build(BuildContext context) {
final colorScheme = context.theme.colorScheme;
return CoButton(
variant: .card,
onPressed: () => context.push('/detail/${item.id}'),
child: Padding(
padding: const EdgeInsets.all(CoreSpace.space16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, style: context.textStyles.baseSemibold),
const CoGap.space4(),
Text(item.subtitle, style: context.textStyles.smNormal),
],
),
),
CoIcon(
CoLucideIcons.chevronRight,
iconStyle: CoreIconStyle(color: colorScheme.base300.toValue()),
),
],
),
),
);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CoIcon(CoLucideIcons.inbox, iconStyle: CoreIconStyle(size: 64)),
const CoGap.space16(),
Text('ํญ๋ชฉ์ด ์์ต๋๋ค', style: context.textStyles.lgNormal),
const CoGap.space8(),
Text('์ ํญ๋ชฉ์ ์ถ๊ฐํด๋ณด์ธ์', style: context.textStyles.smNormal),
],
),
);
}
}
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: CoLucideIcons.house),
_TabInfo(title: '๋ ๋ฒ์งธ', icon: CoLucideIcons.user),
_TabInfo(title: '์ธ ๋ฒ์งธ', icon: CoLucideIcons.settings),
];
return Scaffold(
headers: [
AppBar(
title: Text('ํญ ํ์ด์ง'),
),
],
child: Column(
children: [
// Tab bar
TabList(
selectedIndex: selectedTab.value,
onSelectedIndexChanged: (index) => selectedTab.value = index,
tabs: tabs.map((tab) => Tab(label: tab.title)).toList(),
),
// 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) {
final colorScheme = context.theme.colorScheme;
return CoButton(
variant: .card,
onPressed: onTap,
child: Container(
padding: const EdgeInsets.all(CoreSpace.space16),
decoration: BoxDecoration(
color: colorScheme.base200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Icon area
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: CoIcon(
CoLucideIcons.star,
iconStyle: CoreIconStyle(color: colorScheme.primary.toValue()),
),
),
),
const CoGap.space12(),
// Text area
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: context.textStyles.baseSemibold),
const CoGap.space4(),
Text(subtitle, style: context.textStyles.smNormal),
],
),
),
// Arrow
CoIcon(
CoLucideIcons.chevronRight,
iconStyle: CoreIconStyle(
size: 20,
color: colorScheme.base300.toValue(),
),
),
],
),
),
);
}
}
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 {
final result = await showDialog<bool>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.2),
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
CoButton(
variant: .ghost,
onPressed: () => Navigator.pop(context, false),
child: Text(cancelText),
),
CoButton(
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,
barrierColor: Colors.black.withValues(alpha: 0.2),
builder: (context) => AlertDialog(
title: Text(title),
content: CoInput(
controller: controller,
placeholder: placeholder,
),
actions: [
CoButton(
variant: .ghost,
onPressed: () => Navigator.pop(context),
child: Text(cancelText),
),
CoButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text(confirmText),
),
],
),
);
controller.dispose();
return result;
}
7. BottomSheet Template#
Option Selection BottomSheet#
/// Show option selection BottomSheet
Future<T?> showOptionsSheet<T>(
BuildContext context, {
required String title,
required List<OptionItem<T>> options,
}) {
return showModalBottomSheet<T>(
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,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: context.theme.colorScheme.base300,
borderRadius: BorderRadius.circular(2),
),
),
),
const CoGap.space16(),
// Title
Text(title, style: context.textStyles.lgSemibold),
const CoGap.space16(),
// Option list
...options.map(
(option) => ListTile(
leading: option.icon != null ? CoIcon(option.icon!) : null,
title: Text(option.label),
onTap: () => Navigator.pop(context, option.value),
),
),
const CoGap.space8(),
],
),
),
),
);
}
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: CoLucideIcons.arrowDown),
OptionItem(label: '์ค๋๋์', value: SortOrder.oldest, icon: CoLucideIcons.arrowUp),
OptionItem(label: '์ด๋ฆ์', value: SortOrder.name, icon: CoLucideIcons.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 Bloc<MyEvent, MyState> {
/// Creates [MyBloc] and sets initial state.
MyBloc() : super(const MyState()) {
on<_Started>(_onStarted);
on<_Refreshed>(_onRefreshed);
on<_LoadMore>(_onLoadMore);
}
Future<void> _onStarted(_Started event, Emitter<MyState> emit) async {
emit(state.copyWith(status: LoadingStatus.loading));
// [TODO] Implement data load logic
try {
final items = await _fetchItems();
if (isClosed) return;
emit(state.copyWith(
status: LoadingStatus.success,
items: items,
hasMore: items.length >= 20,
));
} on Exception catch (e) {
if (isClosed) return;
emit(state.copyWith(
status: LoadingStatus.failure,
errorMessage: e.toString(),
));
}
}
Future<void> _onRefreshed(_Refreshed event, Emitter<MyState> emit) async {
emit(state.copyWith(status: LoadingStatus.loading));
try {
final items = await _fetchItems();
if (isClosed) return;
emit(state.copyWith(
status: LoadingStatus.success,
items: items,
hasMore: items.length >= 20,
));
} on Exception {
if (isClosed) return;
emit(state.copyWith(status: LoadingStatus.failure));
}
}
Future<void> _onLoadMore(_LoadMore event, Emitter<MyState> emit) async {
if (!state.hasMore || state.status == LoadingStatus.loading) return;
try {
final newItems = await _fetchItems(offset: state.items.length);
if (isClosed) return;
emit(state.copyWith(
items: [...state.items, ...newItems],
hasMore: newItems.length >= 20,
));
} on Exception {
// Infinite scroll ์คํจ๋ ๋ฌด์
}
}
// [TODO] Replace with actual data load logic
Future<List<MyItem>> _fetchItems({int offset = 0}) async {
// API call or Repository usage
return [];
}
}
Usage#
- Select the appropriate template.
- Find
[TODO]comments and customize for your project. - Change types, names, and logic.
- Delete unnecessary parts.