Skip to content

Instantly share code, notes, and snippets.

@jogboms
Last active January 4, 2022 12:22
Show Gist options
  • Save jogboms/d74163b9742d9cbca4ef3405196bb6e1 to your computer and use it in GitHub Desktop.
Save jogboms/d74163b9742d9cbca4ef3405196bb6e1 to your computer and use it in GitHub Desktop.
Flutter basic workshop - Phase 03

Phase Three

  1. Search logic
  2. Sort logic
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
const String imagesApi = 'https://flutter-introductory-workshop.vercel.app/api/images';
final Store store = Store();
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.light(),
debugShowCheckedModeBanner: false,
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late final ValueNotifier<String> searchValue = ValueNotifier<String>('');
late final ValueNotifier<SortType> sortTypeValue = ValueNotifier<SortType>(SortType.none);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: SharedAppBar(),
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: ToolBarSliverPersistentHeaderDelegate(
(_) => ValueListenableBuilder<SortType>(
valueListenable: sortTypeValue,
builder: (BuildContext context, SortType value, Widget? child) {
return ToolBar(
onSearch: (String value) => searchValue.value = value,
onSort: (SortType value) => sortTypeValue.value = value,
sortType: sortTypeValue.value,
);
},
),
),
),
AnimatedBuilder(
animation: Listenable.merge(<Listenable>[store, searchValue, sortTypeValue]),
builder: (BuildContext context, Widget? child) {
final List<Wine> wines = store.items
.where((Wine element) =>
searchValue.value.isEmpty || element.name.toLowerCase().contains(searchValue.value.toLowerCase()))
.toList(growable: false)
..sort((Wine a, Wine b) {
switch (sortTypeValue.value) {
case SortType.year:
return a.year.compareTo(b.year);
case SortType.rating:
return b.rating.compareTo(a.rating);
case SortType.name:
return a.name.compareTo(b.name);
case SortType.none:
return a.id.compareTo(b.id);
}
});
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 1 / 1.25,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final Wine wine = wines[index];
return GestureDetector(
key: Key(wine.id),
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => DetailsPage(wine: wine)),
);
},
child: WineCard(wine: wine),
);
},
childCount: wines.length,
),
),
);
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
final Wine wine = store.add();
Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => DetailsPage(wine: wine)),
);
},
child: const Icon(Icons.add),
),
);
}
}
class DetailsPage extends StatefulWidget {
const DetailsPage({Key? key, required this.wine}) : super(key: key);
final Wine wine;
@override
_DetailsPageState createState() => _DetailsPageState();
}
class _DetailsPageState extends State<DetailsPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: SharedAppBar(
trailing: IconButton(
onPressed: () {
store.remove(widget.wine.id);
Navigator.pop(context);
},
icon: const Icon(Icons.delete),
),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
AspectRatio(aspectRatio: 1.5, child: Image.network('$imagesApi/${widget.wine.imageId}')),
const SizedBox(height: 24),
TextFormField(
initialValue: widget.wine.name,
onChanged: (String value) => store.update(widget.wine.id, name: value),
decoration: const InputDecoration(border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextFormField(
initialValue: '${widget.wine.year}',
onChanged: (String value) => store.update(widget.wine.id, year: int.parse(value)),
decoration: const InputDecoration(border: OutlineInputBorder()),
),
const SizedBox(height: 16),
StarRating(
rating: widget.wine.rating,
onChanged: (int value) => store.update(widget.wine.id, rating: value),
),
],
),
),
);
}
}
class ToolBar extends StatelessWidget {
const ToolBar({Key? key, required this.onSearch, required this.onSort, required this.sortType}) : super(key: key);
final ValueChanged<String> onSearch;
final ValueChanged<SortType> onSort;
final SortType sortType;
@override
Widget build(BuildContext context) {
return Material(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: <Widget>[
Expanded(
child: TextField(
onChanged: onSearch,
decoration: const InputDecoration(hintText: 'Search...', border: OutlineInputBorder()),
),
),
const SizedBox(width: 8),
PopupMenuButton<SortType>(
icon: const Icon(Icons.sort, color: Colors.grey),
onSelected: onSort,
initialValue: sortType,
itemBuilder: (_) => SortType.values
.map((SortType item) => PopupMenuItem<SortType>(value: item, child: Text(item.displayName)))
.toList(growable: false),
),
],
),
),
);
}
}
class SharedAppBar extends AppBar {
SharedAppBar({Key? key, Widget? trailing})
: super(
key: key,
title: const Text('Winery'),
centerTitle: true,
elevation: 0,
foregroundColor: Colors.black,
backgroundColor: Colors.white,
actions: <Widget>[if (trailing != null) trailing],
);
}
class WineCard extends StatelessWidget {
const WineCard({Key? key, required this.wine}) : super(key: key);
final Wine wine;
@override
Widget build(BuildContext context) {
return Material(
elevation: 1,
borderRadius: const BorderRadius.all(Radius.circular(6)),
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
AspectRatio(
aspectRatio: 1,
child: Stack(
children: <Widget>[
Positioned.fill(child: Image.network('$imagesApi/${wine.imageId}')),
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(color: Colors.black38, borderRadius: BorderRadius.circular(10)),
child: Row(
children: <Widget>[
Icon(
Icons.calendar_today,
color: Theme.of(context).colorScheme.onPrimary.withOpacity(.75),
size: 14,
),
const SizedBox(width: 6),
Text(
wine.year.toString(),
style: Theme.of(context).textTheme.caption?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 6),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(wine.name, style: Theme.of(context).textTheme.subtitle1),
),
),
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: StarRating(rating: wine.rating),
),
const SizedBox(height: 12),
],
),
);
}
}
class StarRating extends FormField<int> {
StarRating({Key? key, required int rating, ValueChanged<int>? onChanged})
: super(
key: key,
initialValue: rating,
builder: (FormFieldState<int> field) {
final int rating = field.value ?? 0;
return Row(
children: List<Widget>.generate(5, (int index) {
final int value = index + 1;
final bool isSolid = value <= rating;
return IconButton(
key: ValueKey<int>(index),
padding: EdgeInsets.zero,
constraints: BoxConstraints.tight(const Size.square(24)),
onPressed: onChanged == null
? null
: () {
field.didChange(value);
onChanged.call(value);
},
icon: Icon(
isSolid ? Icons.star : Icons.star_border,
color: isSolid ? Colors.orangeAccent : Colors.grey,
),
);
}),
);
},
);
@override
_StarRatingState createState() => _StarRatingState();
}
class _StarRatingState extends FormFieldState<int> {
@override
void didUpdateWidget(StarRating oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
setValue(widget.initialValue);
}
}
}
class ToolBarSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
ToolBarSliverPersistentHeaderDelegate(this.builder);
Widget Function(BuildContext) builder;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) =>
SizedBox.expand(child: builder(context));
@override
double get maxExtent => minExtent;
@override
double get minExtent => kToolbarHeight;
@override
bool shouldRebuild(ToolBarSliverPersistentHeaderDelegate oldDelegate) => false;
}
class Store with ChangeNotifier {
Store([List<Wine> items = const <Wine>[]])
: _items = items.fold<Map<String, Wine>>(
<String, Wine>{}, (Map<String, Wine> map, Wine wine) => map..putIfAbsent(wine.id, () => wine));
List<Wine> get items => _items.values.toList(growable: false);
final Map<String, Wine> _items;
Wine add() {
final String id = shortHash(1000 + Random().nextInt(1000));
final Wine wine = Wine(id: id, imageId: 1 + Random().nextInt(35), name: '', rating: 0, year: 0);
_items.putIfAbsent(id, () => wine);
notifyListeners();
return wine;
}
void remove(String id) {
_items.removeWhere((String wineId, _) => wineId == id);
notifyListeners();
}
void update(String id, {String? name, int? year, int? rating}) {
_items.update(
id,
(Wine prev) => Wine(
id: prev.id,
imageId: prev.imageId,
name: name ?? prev.name,
rating: rating ?? prev.rating,
year: year ?? prev.year,
),
);
notifyListeners();
}
}
class Wine {
const Wine({required this.id, required this.imageId, required this.name, required this.rating, required this.year});
final String id;
final int imageId;
final String name;
final int rating;
final int year;
@override
bool operator ==(covariant Wine other) =>
identical(this, other) ||
runtimeType == other.runtimeType &&
id == other.id &&
imageId == other.imageId &&
name == other.name &&
rating == other.rating &&
year == other.year;
@override
int get hashCode => id.hashCode ^ imageId.hashCode ^ name.hashCode ^ rating.hashCode ^ year.hashCode;
@override
String toString() => 'Wine{id: $id, imageId:$imageId, name: $name, rating: $rating, year: $year}';
}
enum SortType { none, name, rating, year }
extension SortTypeExtension on SortType {
String get displayName => <SortType, String>{
SortType.none: 'None',
SortType.name: 'Name',
SortType.rating: 'Rating',
SortType.year: 'Year',
}[this]!;
}
@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

Transitioning into an entirely different concept of Slivers. We want to include a fixed header beneath the SharedAppBar and as such, we would be wrapping the contents of body of the HomePage's Scaffold with a CustomScrollView. Then we would replace the GridView with a SliverGrid and this would come with some changes. At the end, it should look exactly as the snippet below.

CustomScrollView(
  slivers: <Widget>[
    AnimatedBuilder(
      animation: store,
      builder: (BuildContext context, Widget? child) {
        return SliverPadding(
          padding: const EdgeInsets.all(16),
          sliver: SliverGrid(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3,
              childAspectRatio: 1 / 1.25,
              mainAxisSpacing: 16,
              crossAxisSpacing: 16,
            ),
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                final Wine wine = store.items[index];
                return GestureDetector(
                  key: Key(wine.id),
                  onTap: () {
                    Navigator.of(context).push<void>(
                      MaterialPageRoute<void>(builder: (_) => DetailsPage(wine: wine)),
                    );
                  },
                  child: WineCard(wine: wine),
                );
              },
              childCount: store.items.length,
            ),
          ),
        );
      },
    ),
  ],
)

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

We introduce a ToolBar widget to house the search and sort feature.

class ToolBar extends StatelessWidget {
  const ToolBar({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Material();
  }
}

In order to have this pinned at the top beneath the SharedAppBar we need to implement a certain SliverPersistentHeaderDelegate with the prefered height of the ToolBar.

class ToolBarSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
  ToolBarSliverPersistentHeaderDelegate(this.builder);

  Widget Function(BuildContext) builder;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) =>
      SizedBox.expand(child: builder(context));

  @override
  double get maxExtent => minExtent;

  @override
  double get minExtent => kToolbarHeight;

  @override
  bool shouldRebuild(ToolBarSliverPersistentHeaderDelegate oldDelegate) => false;
}

And finally putting both together, we place this above the AnimatedBuilder as the first item of the CustomScrollerView's slivers list.

SliverPersistentHeader(
  pinned: true,
  delegate: ToolBarSliverPersistentHeaderDelegate(
        (_) => const ToolBar(),
  ),
)

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

First we implement the search field and include a required onSearch callback for whenever the value changes.

class ToolBar extends StatelessWidget {
  const ToolBar({Key? key, required this.onSearch}) : super(key: key);

  final ValueChanged<String> onSearch;

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 12),
        child: TextField(
          onChanged: onSearch,
          decoration: const InputDecoration(hintText: 'Search...', border: OutlineInputBorder()),
        ),
      ),
    );
  }
}

And make the required changes.

ToolBar(onSearch: (String value) {})

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

Because the UI has to be completely reactive, I decided a persist the value of the search field in a "reactive" wrapper of some sort. Much like how the Store uses the ChangeNotifier the ValueNotifier is an easy-to-use abstraction that can keep a single value type. We create this as a variable in the _HomePageState.

late final ValueNotifier<String> searchValue = ValueNotifier<String>('');

Then we redirect the changes from the search field through it by just changing its value.

ToolBar(onSearch: (String value) => searchValue.value = value)

Then in order for the AnimatedBuilder to rebuild on changes from both the Store and the search field, we use the Listenable.merge utility to combine both ChangeNotifiers into one.

Listenable.merge(<Listenable>[store, searchValue])

Then finally, in the builder of the AnimatedBuilder we implement the "search/filter" logic. So instead of using store.items directly we now use wines to build the grid.

final List<Wine> wines = store.items
    .where((Wine element) =>
        searchValue.value.isEmpty || element.name.toLowerCase().contains(searchValue.value.toLowerCase()))
    .toList(growable: false);

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

We are going to sort on the following parameters.

enum SortType { none, name, rating, year }

extension SortTypeExtension on SortType {
  String get displayName => <SortType, String>{
        SortType.none: 'None',
        SortType.name: 'Name',
        SortType.rating: 'Rating',
        SortType.year: 'Year',
      }[this]!;
}

We improve the ToolBar with a required onSort callback and PopupMenuButton to select the property to sort by.

class ToolBar extends StatelessWidget {
  const ToolBar({Key? key, required this.onSearch, required this.onSort}) : super(key: key);

  final ValueChanged<String> onSearch;
  final ValueChanged<SortType> onSort;

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 12),
        child: Row(
          children: <Widget>[
            Expanded(
              child: TextField(
                onChanged: onSearch,
                decoration: const InputDecoration(hintText: 'Search...', border: OutlineInputBorder()),
              ),
            ),
            const SizedBox(width: 8),
            PopupMenuButton<SortType>(
              icon: const Icon(Icons.sort, color: Colors.grey),
              onSelected: onSort,
              initialValue: SortType.none,
              itemBuilder: (_) => SortType.values
                  .map((SortType item) => PopupMenuItem<SortType>(value: item, child: Text(item.displayName)))
                  .toList(growable: false),
            ),
          ],
        ),
      ),
    );
  }
}

Make required changes.

ToolBar(
  onSearch: (String value) => searchValue.value = value,
  onSort: (SortType value) {},
)

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

Much like the search ValueNotifier. We implement the same behaviour for sorting.

late final ValueNotifier<SortType> sortTypeValue = ValueNotifier<SortType>(SortType.none);
ToolBar(
  onSearch: (String value) => searchValue.value = value,
  onSort: (SortType value) => sortTypeValue.value = value,
)
Listenable.merge(<Listenable>[store, searchValue, sortTypeValue])

And then implement the sort logic. Append this to the previous search logic (Add it after the .toList(growable: false)).

..sort((Wine a, Wine b) {
  switch (sortTypeValue.value) {
    case SortType.year:
      return a.year.compareTo(b.year);
    case SortType.rating:
      return b.rating.compareTo(a.rating);
    case SortType.name:
      return a.name.compareTo(b.name);
    case SortType.none:
      return a.id.compareTo(b.id);
  }
})

@jogboms
Copy link
Author

jogboms commented Dec 30, 2021

As a bonus point, we would want the PopupMenuButton to show the current state of the sorting. Just like the AnimatedBuilder, the ValueListenableBuilder is a handy utility for subscribing to a ValueNotifier. So we wrap the ToolBar and listen only to the sort type so we can know what state its in at all times.

ValueListenableBuilder<SortType>(
  valueListenable: sortTypeValue,
  builder: (BuildContext context, SortType value, Widget? child) {
    return ToolBar(
      onSearch: (String value) => searchValue.value = value,
      onSort: (SortType value) => sortTypeValue.value = value,
      sortType: sortTypeValue.value,
    );
  },
)

Modify the ToolBar to receive the previous SortType and pass that on to the PopupMenuButton

class ToolBar extends StatelessWidget {
  const ToolBar({Key? key, required this.onSearch, required this.onSort, required this.sortType}) : super(key: key);

  final ValueChanged<String> onSearch;
  final ValueChanged<SortType> onSort;
  final SortType sortType;

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 12),
        child: Row(
          children: <Widget>[
            Expanded(
              child: TextField(
                onChanged: onSearch,
                decoration: const InputDecoration(hintText: 'Search...', border: OutlineInputBorder()),
              ),
            ),
            const SizedBox(width: 8),
            PopupMenuButton<SortType>(
              icon: const Icon(Icons.sort, color: Colors.grey),
              onSelected: onSort,
              initialValue: sortType, // Set the previous value here
              itemBuilder: (_) => SortType.values
                  .map((SortType item) => PopupMenuItem<SortType>(value: item, child: Text(item.displayName)))
                  .toList(growable: false),
            ),
          ],
        ),
      ),
    );
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment