mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 18:57: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
|
|
@ -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<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.
|
||||
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<PersonsList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return FutureBuilder<List<Person>>(
|
||||
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: <Widget>[
|
||||
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<Person>(
|
||||
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<EnsemblesList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return FutureBuilder<List<Ensemble>>(
|
||||
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: <Widget>[
|
||||
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<Ensemble>(
|
||||
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<WorksList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return FutureBuilder<List<WorkInfo>>(
|
||||
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: <Widget>[
|
||||
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<WorkInfo>(
|
||||
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<List<RecordingInfo>>(
|
||||
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<RecordingInfo>(
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue