From 06cb32000aff1cfc358cde77be833b81fef5f71e Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sun, 3 May 2020 21:33:39 +0200 Subject: [PATCH] mobile: Use search and pagination --- mobile/lib/selectors/instruments.dart | 54 +++- mobile/lib/widgets/lists.dart | 390 +++++++++++++++++++------- mobile/lib/widgets/texts.dart | 2 - 3 files changed, 327 insertions(+), 119 deletions(-) diff --git a/mobile/lib/selectors/instruments.dart b/mobile/lib/selectors/instruments.dart index bcfbdba..dcf74bf 100644 --- a/mobile/lib/selectors/instruments.dart +++ b/mobile/lib/selectors/instruments.dart @@ -3,6 +3,7 @@ import 'package:musicus_database/musicus_database.dart'; import '../backend.dart'; import '../editors/instrument.dart'; +import '../widgets/lists.dart'; class InstrumentsSelector extends StatefulWidget { final bool multiple; @@ -18,7 +19,10 @@ class InstrumentsSelector extends StatefulWidget { } class _InstrumentsSelectorState extends State { + final _list = GlobalKey>(); + Set selection = {}; + String _search; @override void initState() { @@ -47,15 +51,36 @@ class _InstrumentsSelectorState extends State { ] : null, ), - body: FutureBuilder>( - future: backend.client.getInstruments(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final instrument = snapshot.data[index]; - + body: Column( + children: [ + Material( + elevation: 2.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: TextField( + autofocus: true, + onChanged: (text) { + setState(() { + _search = text; + }); + }, + decoration: InputDecoration.collapsed( + hintText: 'Search by name...', + ), + ), + ), + ), + Expanded( + child: PagedListView( + key: _list, + search: _search, + fetch: (page, search) async { + return await backend.client.getInstruments(page, search); + }, + builder: (context, instrument) { if (widget.multiple) { return CheckboxListTile( title: Text(instrument.name), @@ -78,11 +103,9 @@ class _InstrumentsSelectorState extends State { ); } }, - ); - } else { - return Container(); - } - }, + ), + ), + ], ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add), @@ -99,6 +122,9 @@ class _InstrumentsSelectorState extends State { setState(() { selection.add(instrument); }); + + // We need to rebuild the list view, because we added an item. + _list.currentState.update(); } else { Navigator.pop(context, instrument); } diff --git a/mobile/lib/widgets/lists.dart b/mobile/lib/widgets/lists.dart index 6e793f0..71f440a 100644 --- a/mobile/lib/widgets/lists.dart +++ b/mobile/lib/widgets/lists.dart @@ -1,95 +1,272 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; import '../backend.dart'; import '../widgets/texts.dart'; +/// A list view supporting pagination and searching. +/// +/// The [fetch] function will be called, when the user has scrolled to the end +/// of the list. If you recreate this widget with a new [search] parameter, it +/// will update, but it will NOT correctly react to a changed [fetch] method. +/// You can call update() on the corresponding state object to manually refresh +/// the contents. +class PagedListView extends StatefulWidget { + /// A search string. + /// + /// This will be provided when calling [fetch]. + final String search; + + /// Callback for fetching a page of entities. + /// + /// This has to tolerate abitrary high page numbers. + final Future> Function(int page, String search) fetch; + + /// Build function to be called for each entity. + final Widget Function(BuildContext context, T entity) builder; + + PagedListView({ + Key key, + this.search, + @required this.fetch, + @required this.builder, + }) : super(key: key); + + @override + PagedListViewState createState() => PagedListViewState(); +} + +class PagedListViewState extends State> { + final _scrollController = ScrollController(); + final _entities = []; + + bool loading = true; + + /// The last parameters of _fetch(). + int _page; + String _search; + + /// Whether the last fetch() call returned no results. + bool _end = false; + + /// Fetch new entities. + /// + /// If the function was called again with other parameters, while it was + /// running, it will discard the result. + Future _fetch(int page, String search) async { + if (page != _page || search != _search) { + _page = page; + _search = search; + + setState(() { + loading = true; + }); + + final newEntities = await widget.fetch(page, search); + + if (mounted && search == _search) { + setState(() { + if (newEntities.isNotEmpty) { + _entities.addAll(newEntities); + } else { + _end = true; + } + + loading = false; + }); + } + } + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(() { + if (_scrollController.position.pixels > + _scrollController.position.maxScrollExtent - 64.0 && + !loading && + !_end) { + _fetch(_page + 1, widget.search); + } + }); + + _fetch(0, widget.search); + } + + /// Update the content manually. + /// + /// This will reset the current page to zero and call the provided fetch() + /// method. + void update() { + setState(() { + _entities.clear(); + }); + + _page = null; + _fetch(0, widget.search); + } + + @override + void didUpdateWidget(PagedListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.search != widget.search) { + // We don't nedd to call setState() because the framework will always call + // build() after this. + _entities.clear(); + _page = null; + _fetch(0, widget.search); + } + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: _scrollController, + itemCount: _entities.length + 1, + itemBuilder: (context, index) { + if (index < _entities.length) { + return widget.builder(context, _entities[index]); + } else { + return SizedBox( + height: 64.0, + child: Center( + child: loading ? CircularProgressIndicator() : Container(), + ), + ); + } + }, + ); + } +} + /// A list of persons. -class PersonsList extends StatelessWidget { +class PersonsList extends StatefulWidget { /// Called, when the user has selected a person. final void Function(Person person) onSelected; PersonsList({ - this.onSelected, + @required this.onSelected, }); + @override + _PersonsListState createState() => _PersonsListState(); +} + +class _PersonsListState extends State { + String _search; + @override Widget build(BuildContext context) { final backend = Backend.of(context); - return FutureBuilder>( - future: backend.client.getPersons(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final person = snapshot.data[index]; - - return ListTile( - title: Text('${person.lastName}, ${person.firstName}'), - onTap: () { - if (onSelected != null) { - onSelected(person); - } - }, - ); + return Column( + children: [ + Material( + elevation: 2.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: TextField( + autofocus: true, + onChanged: (text) { + setState(() { + _search = text; + }); + }, + decoration: InputDecoration.collapsed( + hintText: 'Search by last name...', + ), + ), + ), + ), + Expanded( + child: PagedListView( + search: _search, + fetch: (page, search) async { + return await backend.client.getPersons(page, search); }, - ); - } else { - return Center( - child: CircularProgressIndicator(), - ); - } - }, + builder: (context, person) => ListTile( + title: Text('${person.lastName}, ${person.firstName}'), + onTap: () { + widget.onSelected(person); + }, + ), + ), + ), + ], ); } } /// A list of ensembles. -class EnsemblesList extends StatelessWidget { +class EnsemblesList extends StatefulWidget { /// Called, when the user has selected an ensemble. final void Function(Ensemble ensemble) onSelected; EnsemblesList({ - this.onSelected, + @required this.onSelected, }); + @override + _EnsemblesListState createState() => _EnsemblesListState(); +} + +class _EnsemblesListState extends State { + String _search; + @override Widget build(BuildContext context) { final backend = Backend.of(context); - return FutureBuilder>( - future: backend.client.getEnsembles(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final ensemble = snapshot.data[index]; - - return ListTile( - title: Text(ensemble.name), - onTap: () { - if (onSelected != null) { - onSelected(ensemble); - } - }, - ); + return Column( + children: [ + Material( + elevation: 2.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: TextField( + autofocus: true, + onChanged: (text) { + setState(() { + _search = text; + }); + }, + decoration: InputDecoration.collapsed( + hintText: 'Search by name...', + ), + ), + ), + ), + Expanded( + child: PagedListView( + search: _search, + fetch: (page, search) async { + return await backend.client.getEnsembles(page, search); }, - ); - } else { - return Center( - child: CircularProgressIndicator(), - ); - } - }, + builder: (context, ensemble) => ListTile( + title: Text(ensemble.name), + onTap: () { + widget.onSelected(ensemble); + }, + ), + ), + ), + ], ); } } /// A list of works by one composer. -class WorksList extends StatelessWidget { +class WorksList extends StatefulWidget { /// The ID of the composer. final int personId; @@ -101,35 +278,55 @@ class WorksList extends StatelessWidget { this.onSelected, }); + @override + _WorksListState createState() => _WorksListState(); +} + +class _WorksListState extends State { + String _search; + @override Widget build(BuildContext context) { final backend = Backend.of(context); - return FutureBuilder>( - future: backend.client.getWorks(personId), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final workInfo = snapshot.data[index]; - - return ListTile( - title: Text(workInfo.work.title), - onTap: () { - if (onSelected != null) { - onSelected(workInfo); - } - }, - ); + return Column( + children: [ + Material( + elevation: 2.0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + child: TextField( + autofocus: true, + onChanged: (text) { + setState(() { + _search = text; + }); + }, + decoration: InputDecoration.collapsed( + hintText: 'Search by title...', + ), + ), + ), + ), + Expanded( + child: PagedListView( + search: _search, + fetch: (page, search) async { + return await backend.client + .getWorks(widget.personId, page, search); }, - ); - } else { - return Center( - child: CircularProgressIndicator(), - ); - } - }, + builder: (context, workInfo) => ListTile( + title: Text(workInfo.work.title), + onTap: () { + widget.onSelected(workInfo); + }, + ), + ), + ), + ], ); } } @@ -151,33 +348,20 @@ class RecordingsList extends StatelessWidget { Widget build(BuildContext context) { final backend = Backend.of(context); - return FutureBuilder>( - future: backend.client.getRecordings(workId), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final recordingInfo = snapshot.data[index]; - - return ListTile( - title: PerformancesText( - performanceInfos: recordingInfo.performances, - ), - onTap: () { - if (onSelected != null) { - onSelected(recordingInfo); - } - }, - ); - }, - ); - } else { - return Center( - child: CircularProgressIndicator(), - ); - } + return PagedListView( + fetch: (page, _) async { + return await backend.client.getRecordings(workId, page); }, + builder: (context, recordingInfo) => ListTile( + title: PerformancesText( + performanceInfos: recordingInfo.performances, + ), + onTap: () { + if (onSelected != null) { + onSelected(recordingInfo); + } + }, + ), ); } } diff --git a/mobile/lib/widgets/texts.dart b/mobile/lib/widgets/texts.dart index 95d4d2a..3128d57 100644 --- a/mobile/lib/widgets/texts.dart +++ b/mobile/lib/widgets/texts.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart';