- Search logic
- Sort logic
-
-
Save jogboms/d74163b9742d9cbca4ef3405196bb6e1 to your computer and use it in GitHub Desktop.
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]!; | |
} |
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 ChangeNotifier
s 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);
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) {},
)
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);
}
})
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),
),
],
),
),
);
}
}
First we implement the search field and include a required
onSearch
callback for whenever the value changes.And make the required changes.