Note Widgets

As a final step, we're going to create a couple of widgets for the note feature:

Widget
Description

A list tile that displays a note entry and navigates to the NoteEntryFormPage when tapped.

A widget for streaming the 10 most recent notes.

A widget for displaying a list of notes in a paginated manner.

A page that allows the user to create, update and delete a note entry.

The main page for the note feature showcasing the StreamNotesListView, PaginatedNotesListView and a floating action button for creating a new note entry.

1. Widgets

In the note_app/modules/presentation/lib/note/widgets/ directory, create the following files:

1.1. Create the NoteEntryListTile

Make a file named note_entry_list_tile.dart and paste the code below:

import 'package:domain/note/entities/note_entry.dart';
import 'package:flutter/material.dart';
import 'package:presentation/note/pages/note_entry_form_page.dart';

/// {@template NoteEntryListTile}
/// A [ListTile] that displays a [NoteEntry] and navigates to the
/// [NoteEntryFormPage] when tapped.
/// {@endtemplate}
class NoteEntryListTile extends StatelessWidget {
  /// {@macro NoteEntryListTile}
  const NoteEntryListTile({super.key, required this.noteEntry});

  /// The [NoteEntry] to display.
  final NoteEntry noteEntry;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      key: ValueKey(noteEntry.id),
      title: Text(noteEntry.title.isEmpty ? 'No title' : noteEntry.title),
      subtitle:
          Text(noteEntry.content.isEmpty ? 'No content' : noteEntry.content),
      onTap: () => Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => NoteEntryFormPage(noteEntry: noteEntry),
        ),
      ),
    );
  }
}

1.2. Create the StreamNotesListView

Make a file named stream_notes_list_view.dart and paste the code below:

import 'package:domain/note/entities/note_entry.dart';
import 'package:domain/note/use_cases/watch_note_entries.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:presentation/note/widgets/note_entry_list_tile.dart';

/// {@template StreamNotesListView}
/// A widget for displaying a list of [NoteEntry]s emitted by the
/// [WatchNoteEntries] use case.
/// {@endtemplate}
class StreamNotesListView extends StatefulWidget {
  /// {@macro StreamNotesListView}
  const StreamNotesListView({super.key});

  @override
  State<StreamNotesListView> createState() => _StreamNotesListViewState();
}

class _StreamNotesListViewState extends State<StreamNotesListView> {
  @override
  Widget build(BuildContext context) {
    final noteEntries = context.select<WatchNoteEntries, List<NoteEntry>>(
      (useCase) => useCase.rightEvent ?? [],
    );

    if (noteEntries.isEmpty) {
      return Container();
    }

    return ListView.builder(
      itemCount: noteEntries.length,
      itemBuilder: (context, index) =>
          NoteEntryListTile(noteEntry: noteEntries[index]),
    );
  }
}

1.3. Create the PaginatedNotesListView

Make a file named paginated_notes_list_view.dart and paste the code below:

The infinite_scroll_pagination package is utilized to implement pagination.

import 'package:domain/domain.dart';
import 'package:domain/note/entities/note_entry.dart';
import 'package:domain/note/use_cases/fetch_note_entries.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:presentation/note/widgets/note_entry_list_tile.dart';

/// {@template PaginatedNotesListView}
/// A widget for displaying a list of [NoteEntry]s paginated by the
/// [FetchNoteEntries] use case.
/// {@endtemplate}
class PaginatedNotesListView extends StatefulWidget {
  const PaginatedNotesListView({super.key});

  @override
  State<PaginatedNotesListView> createState() => _PaginatedNotesListViewState();
}

class _PaginatedNotesListViewState extends State<PaginatedNotesListView> {
  /// A controller containing all the [NoteEntry]s displayed on the list.
  final _pagingController =
      PagingController<dynamic, NoteEntry>(firstPageKey: 0);

  @override
  void initState() {
    super.initState();

    // Fetch the first page of note entries
    _fetchNoteEntries(0);

    // Add a listener to the paging controller so that we can fetch the next 
    // page of note entries when the user scrolls to the end of the list
    _pagingController
        .addPageRequestListener((pageKey) => _fetchNoteEntries(pageKey));
  }

  @override
  void dispose() {
    _pagingController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<FetchNoteEntries, RunnerState>(
      listener: (context, fetchNoteEntriesState) {
        if (fetchNoteEntriesState is RunSuccess<List<NoteEntry>>) {
          // Update the note entries in the paging controller when the
          // [FetchNoteEntries] use case succeeds
          _updatePagingController();
        }
      },
      builder: (context, fetchNoteEntriesState) {
        return PagedListView<dynamic, NoteEntry>(
          pagingController: _pagingController,
          shrinkWrap: true,
          builderDelegate: PagedChildBuilderDelegate<NoteEntry>(
            itemBuilder: (context, noteEntry, index) =>
                NoteEntryListTile(noteEntry: noteEntry),
          ),
        );
      },
    );
  }

  /// Fetches a collection of note entries respective to the given [pageKey].
  void _fetchNoteEntries(dynamic pageKey) {
    context.read<FetchNoteEntries>().run(
          params: FetchNoteEntriesParams(pageToken: pageKey),
        );
  }

  /// Updates the note entries in the paging controller.
  void _updatePagingController() {
    final fetchNoteEntries = context.read<FetchNoteEntries>();

    final params = fetchNoteEntries.rightParams;
    final noteEntries = fetchNoteEntries.rightValue;

    if (params == null || noteEntries == null) {
      return;
    }

    if (params.pageToken == 0) {
      // If the token used to fetch the note entries is `0`, then we are
      // fetching the first page
      _pagingController.refresh();
    }

    if (noteEntries.length < params.limit) {
      // If the number of note entries is less than the limit, then we have
      // reached the end of the list
      _pagingController.appendLastPage(noteEntries);
    } else {
      _pagingController.appendPage(
        noteEntries,
        params.pageToken + noteEntries.length,
      );
    }
  }
}

2. Pages

In the note_app/modules/presentation/lib/note/pages/ directory, create the following files:

2.1. Create the NoteEntryFormPage

Make a file named note_entry_form_page.dart and paste the code below:

import 'package:domain/note/entities/note_entry.dart';
import 'package:domain/note/use_cases/create_note_entry.dart';
import 'package:domain/note/use_cases/delete_note_entry.dart';
import 'package:domain/note/use_cases/update_note_entry.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// {@template NoteEntryFormPage}
/// A page that allows the user to create, edit or delete a [NoteEntry].
/// {@endtemplate}
class NoteEntryFormPage extends StatefulWidget {
  const NoteEntryFormPage({super.key, this.noteEntry});

  /// The note entry to edit. If `null`, a new note entry will be created.
  final NoteEntry? noteEntry;

  @override
  State<NoteEntryFormPage> createState() => _NoteEntryFormPageState();
}

class _NoteEntryFormPageState extends State<NoteEntryFormPage> {
  /// The text controller for the note title.
  late final _titleController = TextEditingController(
      text: widget.noteEntry != null ? widget.noteEntry!.title : null);

  /// The text controller for the note content.
  late final _contentController = TextEditingController(
      text: widget.noteEntry != null ? widget.noteEntry!.content : null);

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: [
          _saveButton(),
          if (widget.noteEntry != null) _deleteButton(),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.symmetric(horizontal: 24),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 24),
          child: Column(
            children: [_titleField(), _contentField()],
          ),
        ),
      ),
    );
  }

  /// Returns a text field widget for the note title.
  Widget _titleField() => TextFormField(
        controller: _titleController,
        decoration: const InputDecoration(hintText: 'Title'),
      );

  /// Returns a text field widget for the note content.
  Widget _contentField() => TextFormField(
        controller: _contentController,
        maxLines: null,
        decoration: const InputDecoration(
          border: InputBorder.none,
          hintText: 'Write your note here...',
        ),
      );

  /// Creates the note entry if it is new. Otherwise, updates the existing note.
  Widget _saveButton() {
    return IconButton(
      onPressed: () async {
        final result = widget.noteEntry == null
            ? await context.read<CreateNoteEntry>().run(
                  params: CreateNoteEntryParams(
                    title: _titleController.text,
                    content: _contentController.text,
                  ),
                )
            : await context.read<UpdateNoteEntry>().run(
                  params: UpdateNoteEntryParams(
                    id: widget.noteEntry!.id,
                    title: _titleController.text,
                    content: _contentController.text,
                  ),
                );

        if (!mounted) {
          return;
        }

        if (result?.isRight() ?? false) {
          Navigator.of(context).pop();
        }
      },
      icon: const Icon(Icons.save),
    );
  }

  /// Deletes the note entry.
  Widget _deleteButton() {
    return IconButton(
      onPressed: () async {
        final result = await context.read<DeleteNoteEntry>().run(
              params: DeleteNoteEntryParams(id: widget.noteEntry!.id),
            );

        if (!mounted) {
          return;
        }

        if (result?.isRight() ?? false) {
          Navigator.of(context).pop();
        }
      },
      icon: const Icon(Icons.delete),
    );
  }
}

2.2. Create the HomePage

Make a file named home_page.dart and paste the code below:

import 'package:flutter/material.dart';
import 'package:presentation/note/pages/note_entry_form_page.dart';
import 'package:presentation/note/widgets/paginated_notes_list_view.dart';
import 'package:presentation/note/widgets/stream_notes_list_view.dart';

/// {@template HomePage}
/// The home page for the note feature.
/// {@endtemplate}
class HomePage extends StatefulWidget {
  /// {@macro HomePage}
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// The views that displayed on the home page.
  final _homeViews = <Widget>[
    const StreamNotesListView(),
    const PaginatedNotesListView(),
  ];

  /// An index for the currently displayed view from the [_homeViews].
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(child: _homeViews[_selectedIndex]),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => const NoteEntryFormPage(),
          ),
        ),
        child: const Icon(Icons.add),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: (value) {
          setState(() => _selectedIndex = value);
        },
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.short_text),
            label: 'Recent (Stream)',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.notes),
            label: 'All (Paginated)',
          ),
        ],
      ),
    );
  }
}

Once all widgets are created, you're done! 🎉

Last updated