mobile: Use search and pagination

This commit is contained in:
Elias Projahn 2020-05-03 21:33:39 +02:00
parent 198391ca96
commit 06cb32000a
3 changed files with 327 additions and 119 deletions

View file

@ -3,6 +3,7 @@ import 'package:musicus_database/musicus_database.dart';
import '../backend.dart'; import '../backend.dart';
import '../editors/instrument.dart'; import '../editors/instrument.dart';
import '../widgets/lists.dart';
class InstrumentsSelector extends StatefulWidget { class InstrumentsSelector extends StatefulWidget {
final bool multiple; final bool multiple;
@ -18,7 +19,10 @@ class InstrumentsSelector extends StatefulWidget {
} }
class _InstrumentsSelectorState extends State<InstrumentsSelector> { class _InstrumentsSelectorState extends State<InstrumentsSelector> {
final _list = GlobalKey<PagedListViewState<Instrument>>();
Set<Instrument> selection = {}; Set<Instrument> selection = {};
String _search;
@override @override
void initState() { void initState() {
@ -47,15 +51,36 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
] ]
: null, : null,
), ),
body: FutureBuilder<List<Instrument>>( body: Column(
future: backend.client.getInstruments(), children: <Widget>[
builder: (context, snapshot) { Material(
if (snapshot.hasData) { elevation: 2.0,
return ListView.builder( child: Padding(
itemCount: snapshot.data.length, padding: const EdgeInsets.symmetric(
itemBuilder: (context, index) { horizontal: 16.0,
final instrument = snapshot.data[index]; vertical: 4.0,
),
child: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText: 'Search by name...',
),
),
),
),
Expanded(
child: PagedListView<Instrument>(
key: _list,
search: _search,
fetch: (page, search) async {
return await backend.client.getInstruments(page, search);
},
builder: (context, instrument) {
if (widget.multiple) { if (widget.multiple) {
return CheckboxListTile( return CheckboxListTile(
title: Text(instrument.name), title: Text(instrument.name),
@ -78,11 +103,9 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
); );
} }
}, },
); ),
} else { ),
return Container(); ],
}
},
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add), child: const Icon(Icons.add),
@ -99,6 +122,9 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
setState(() { setState(() {
selection.add(instrument); selection.add(instrument);
}); });
// We need to rebuild the list view, because we added an item.
_list.currentState.update();
} else { } else {
Navigator.pop(context, instrument); Navigator.pop(context, instrument);
} }

View file

@ -1,95 +1,272 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart'; import '../backend.dart';
import '../widgets/texts.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<T> 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<List<T>> 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<T> createState() => PagedListViewState<T>();
}
class PagedListViewState<T> extends State<PagedListView<T>> {
final _scrollController = ScrollController();
final _entities = <T>[];
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<void> _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<T> 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. /// A list of persons.
class PersonsList extends StatelessWidget { class PersonsList extends StatefulWidget {
/// Called, when the user has selected a person. /// Called, when the user has selected a person.
final void Function(Person person) onSelected; final void Function(Person person) onSelected;
PersonsList({ PersonsList({
this.onSelected, @required this.onSelected,
}); });
@override
_PersonsListState createState() => _PersonsListState();
}
class _PersonsListState extends State<PersonsList> {
String _search;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = Backend.of(context);
return FutureBuilder<List<Person>>( return Column(
future: backend.client.getPersons(), children: <Widget>[
builder: (context, snapshot) { Material(
if (snapshot.hasData) { elevation: 2.0,
return ListView.builder( child: Padding(
itemCount: snapshot.data.length, padding: const EdgeInsets.symmetric(
itemBuilder: (context, index) { horizontal: 16.0,
final person = snapshot.data[index]; vertical: 4.0,
),
return ListTile( child: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText: 'Search by last name...',
),
),
),
),
Expanded(
child: PagedListView<Person>(
search: _search,
fetch: (page, search) async {
return await backend.client.getPersons(page, search);
},
builder: (context, person) => ListTile(
title: Text('${person.lastName}, ${person.firstName}'), title: Text('${person.lastName}, ${person.firstName}'),
onTap: () { onTap: () {
if (onSelected != null) { widget.onSelected(person);
onSelected(person);
}
},
);
},
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
}, },
),
),
),
],
); );
} }
} }
/// A list of ensembles. /// A list of ensembles.
class EnsemblesList extends StatelessWidget { class EnsemblesList extends StatefulWidget {
/// Called, when the user has selected an ensemble. /// Called, when the user has selected an ensemble.
final void Function(Ensemble ensemble) onSelected; final void Function(Ensemble ensemble) onSelected;
EnsemblesList({ EnsemblesList({
this.onSelected, @required this.onSelected,
}); });
@override
_EnsemblesListState createState() => _EnsemblesListState();
}
class _EnsemblesListState extends State<EnsemblesList> {
String _search;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = Backend.of(context);
return FutureBuilder<List<Ensemble>>( return Column(
future: backend.client.getEnsembles(), children: <Widget>[
builder: (context, snapshot) { Material(
if (snapshot.hasData) { elevation: 2.0,
return ListView.builder( child: Padding(
itemCount: snapshot.data.length, padding: const EdgeInsets.symmetric(
itemBuilder: (context, index) { horizontal: 16.0,
final ensemble = snapshot.data[index]; vertical: 4.0,
),
return ListTile( child: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText: 'Search by name...',
),
),
),
),
Expanded(
child: PagedListView<Ensemble>(
search: _search,
fetch: (page, search) async {
return await backend.client.getEnsembles(page, search);
},
builder: (context, ensemble) => ListTile(
title: Text(ensemble.name), title: Text(ensemble.name),
onTap: () { onTap: () {
if (onSelected != null) { widget.onSelected(ensemble);
onSelected(ensemble);
}
},
);
},
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
}, },
),
),
),
],
); );
} }
} }
/// A list of works by one composer. /// A list of works by one composer.
class WorksList extends StatelessWidget { class WorksList extends StatefulWidget {
/// The ID of the composer. /// The ID of the composer.
final int personId; final int personId;
@ -101,35 +278,55 @@ class WorksList extends StatelessWidget {
this.onSelected, this.onSelected,
}); });
@override
_WorksListState createState() => _WorksListState();
}
class _WorksListState extends State<WorksList> {
String _search;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = Backend.of(context);
return FutureBuilder<List<WorkInfo>>( return Column(
future: backend.client.getWorks(personId), children: <Widget>[
builder: (context, snapshot) { Material(
if (snapshot.hasData) { elevation: 2.0,
return ListView.builder( child: Padding(
itemCount: snapshot.data.length, padding: const EdgeInsets.symmetric(
itemBuilder: (context, index) { horizontal: 16.0,
final workInfo = snapshot.data[index]; vertical: 4.0,
),
return ListTile( child: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText: 'Search by title...',
),
),
),
),
Expanded(
child: PagedListView<WorkInfo>(
search: _search,
fetch: (page, search) async {
return await backend.client
.getWorks(widget.personId, page, search);
},
builder: (context, workInfo) => ListTile(
title: Text(workInfo.work.title), title: Text(workInfo.work.title),
onTap: () { onTap: () {
if (onSelected != null) { widget.onSelected(workInfo);
onSelected(workInfo);
}
},
);
},
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
}, },
),
),
),
],
); );
} }
} }
@ -151,16 +348,11 @@ class RecordingsList extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = Backend.of(context);
return FutureBuilder<List<RecordingInfo>>( return PagedListView<RecordingInfo>(
future: backend.client.getRecordings(workId), fetch: (page, _) async {
builder: (context, snapshot) { return await backend.client.getRecordings(workId, page);
if (snapshot.hasData) { },
return ListView.builder( builder: (context, recordingInfo) => ListTile(
itemCount: snapshot.data.length,
itemBuilder: (context, index) {
final recordingInfo = snapshot.data[index];
return ListTile(
title: PerformancesText( title: PerformancesText(
performanceInfos: recordingInfo.performances, performanceInfos: recordingInfo.performances,
), ),
@ -169,15 +361,7 @@ class RecordingsList extends StatelessWidget {
onSelected(recordingInfo); onSelected(recordingInfo);
} }
}, },
); ),
},
);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
},
); );
} }
} }

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';