Flutter UI Patterns - Templates#
Ready-to-use code templates that can be copied directly. Customization points are marked with comments.
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: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.arrowLeft),
onPressed: () => context.pop(),
),
],
// [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: EdgeInsets.symmetric(horizontal: Spacing.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap.s4(),
// [TODO] Implement content
Text('ėģ¨í
ė¸ ').lg.bold.baseContent,
],
),
);
}
}
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: [
HeroIcon(HeroIcons.exclamationCircle, size: 48),
const Gap.s4(),
Text(message).md.baseContent,
const Gap.s4(),
Button.primary(
onPressed: () {
context.read<MyBloc>().add(const MyEvent.retried());
},
child: 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: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.arrowLeft),
onPressed: () => context.pop(),
),
],
),
],
child: Padding(
padding: EdgeInsets.symmetric(horizontal: Spacing.s4),
child: Column(
children: [
const Gap.s4(),
// [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: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.xMark),
onPressed: () => context.pop(),
),
],
),
],
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: Spacing.s4),
child: Form(
key: formKey,
onChanged: validate,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap.s4(),
// [TODO] Define form fields
FormTextField(
controller: nameController,
label: Text('ė´ëĻ'),
placeholder: 'ė´ëĻė ė
ë Ĩíė¸ė',
validator: (value) {
if (value == null || value.isEmpty) {
return 'ė´ëĻė ė
ë Ĩíė¸ė';
}
return null;
},
),
const Gap.s4(),
FormTextField(
controller: emailController,
label: Text('ė´ëŠėŧ'),
placeholder: 'ė´ëŠėŧė ė
ë Ĩíė¸ė',
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'ė´ëŠėŧė ė
ë Ĩíė¸ė';
}
if (!value.contains('@')) {
return 'ėŦë°ëĨ¸ ė´ëŠėŧ íėė´ ėëëë¤';
}
return null;
},
),
const Spacer(),
// ė ėļ ë˛íŧ
Button.primary(
expanded: true,
enabled: isValid.value && !isLoading.value,
onPressed: submit,
child: isLoading.value
? const CircularProgressIndicator()
: Text('ė ėļ'),
),
const Gap.s4(),
],
),
),
),
),
);
}
}
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: [
IconButton.ghost(
icon: HeroIcon(HeroIcons.plus),
onPressed: () => context.push('/create'),
),
],
),
],
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: EdgeInsets.all(Spacing.s4),
itemCount: state.items.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, __) => const Gap.s2(),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
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 Button.card(
onPressed: () => context.push('/detail/${item.id}'),
child: Padding(
padding: EdgeInsets.all(Spacing.s4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title).md.semiBold.baseContent,
const Gap.s1(),
Text(item.subtitle).sm.base200,
],
),
),
HeroIcon(
HeroIcons.chevronRight,
color: colorScheme.base300,
),
],
),
),
);
}
}
class _EmptyView extends StatelessWidget {
const _EmptyView();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
HeroIcon(HeroIcons.inbox, size: 64),
const Gap.s4(),
Text('íëĒŠė´ ėėĩëë¤').lg.base200,
const Gap.s2(),
Text('ė íëĒŠė ėļę°í´ëŗ´ė¸ė').sm.base300,
],
),
);
}
}
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: HeroIcons.home),
_TabInfo(title: 'ë ë˛ė§¸', icon: HeroIcons.user),
_TabInfo(title: 'ė¸ ë˛ė§¸', icon: HeroIcons.cog6Tooth),
];
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 HeroIcons 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 Button.card(
onPressed: onTap,
child: Container(
padding: EdgeInsets.all(Spacing.s4),
decoration: BoxDecoration(
color: colorScheme.base200,
borderRadius: BorderRadius.circular(RadiusScale.kBox),
),
child: Row(
children: [
// Icon area
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(RadiusScale.kRound),
),
child: Center(
child: HeroIcon(
HeroIcons.star,
color: colorScheme.primary,
),
),
),
const Gap.s3(),
// Text area
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title).md.semiBold.baseContent,
const Gap.s1(),
Text(subtitle).sm.base200,
],
),
),
// Arrow
HeroIcon(
HeroIcons.chevronRight,
color: colorScheme.base300,
size: 20,
),
],
),
),
);
}
}
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: [
Button.ghost(
onPressed: () => Navigator.pop(context, false),
child: Text(cancelText),
),
if (isDestructive)
Button.primary(
style: const ButtonStyle.primary().copyWith(
backgroundColor: context.theme.colorScheme.error,
),
onPressed: () => Navigator.pop(context, true),
child: Text(confirmText),
)
else
Button.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: FormTextField(
controller: controller,
placeholder: placeholder,
autofocus: true,
),
actions: [
Button.ghost(
onPressed: () => Navigator.pop(context),
child: Text(cancelText),
),
Button.primary(
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: EdgeInsets.all(Spacing.s4),
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 Gap.s4(),
// Title
Text(title).lg.bold.baseContent,
const Gap.s4(),
// Option list
...options.map(
(option) => ListTile(
leading: option.icon != null
? HeroIcon(option.icon!)
: null,
title: Text(option.label),
onTap: () => Navigator.pop(context, option.value),
),
),
const Gap.s2(),
],
),
),
),
);
}
class OptionItem<T> {
const OptionItem({
required this.label,
required this.value,
this.icon,
});
final String label;
final T value;
final HeroIcons? icon;
}
// Usage example
void onSortTap(BuildContext context) async {
final result = await showOptionsSheet<SortOrder>(
context,
title: 'ė ë Ŧ 기ė¤',
options: [
OptionItem(label: 'ėĩė ė', value: SortOrder.newest, icon: HeroIcons.arrowDown),
OptionItem(label: 'ė¤ëëė', value: SortOrder.oldest, icon: HeroIcons.arrowUp),
OptionItem(label: 'ė´ëĻė', value: SortOrder.name, icon: HeroIcons.bars3),
],
);
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.