LogoSkills

flutter-ui Templates

Ready-to-use code templates that can be copied directly.

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#

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