mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 10:47:25 +01:00
mobile: Use search and pagination
This commit is contained in:
parent
198391ca96
commit
06cb32000a
3 changed files with 327 additions and 119 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue