mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-29 12:07:25 +01:00
Move reusable code from mobile to common
This will be useful for a future desktop application.
This commit is contained in:
parent
6e1255f26e
commit
711b19c998
40 changed files with 813 additions and 581 deletions
367
common/lib/src/widgets/lists.dart
Normal file
367
common/lib/src/widgets/lists.dart
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
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 StatefulWidget {
|
||||
/// Called, when the user has selected a person.
|
||||
final void Function(Person person) onSelected;
|
||||
|
||||
PersonsList({
|
||||
@required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_PersonsListState createState() => _PersonsListState();
|
||||
}
|
||||
|
||||
class _PersonsListState extends State<PersonsList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
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);
|
||||
},
|
||||
builder: (context, person) => ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () {
|
||||
widget.onSelected(person);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of ensembles.
|
||||
class EnsemblesList extends StatefulWidget {
|
||||
/// Called, when the user has selected an ensemble.
|
||||
final void Function(Ensemble ensemble) onSelected;
|
||||
|
||||
EnsemblesList({
|
||||
@required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_EnsemblesListState createState() => _EnsemblesListState();
|
||||
}
|
||||
|
||||
class _EnsemblesListState extends State<EnsemblesList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
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);
|
||||
},
|
||||
builder: (context, ensemble) => ListTile(
|
||||
title: Text(ensemble.name),
|
||||
onTap: () {
|
||||
widget.onSelected(ensemble);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of works by one composer.
|
||||
class WorksList extends StatefulWidget {
|
||||
/// The ID of the composer.
|
||||
final int personId;
|
||||
|
||||
/// Called, when the user has selected a work.
|
||||
final void Function(WorkInfo workInfo) onSelected;
|
||||
|
||||
WorksList({
|
||||
this.personId,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_WorksListState createState() => _WorksListState();
|
||||
}
|
||||
|
||||
class _WorksListState extends State<WorksList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
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);
|
||||
},
|
||||
builder: (context, workInfo) => ListTile(
|
||||
title: Text(workInfo.work.title),
|
||||
onTap: () {
|
||||
widget.onSelected(workInfo);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of recordings of a work.
|
||||
class RecordingsList extends StatelessWidget {
|
||||
/// The ID of the work.
|
||||
final int workId;
|
||||
|
||||
/// Called, when the user has selected a recording.
|
||||
final void Function(RecordingInfo recordingInfo) onSelected;
|
||||
|
||||
RecordingsList({
|
||||
this.workId,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
common/lib/src/widgets/recording_tile.dart
Normal file
49
common/lib/src/widgets/recording_tile.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import 'texts.dart';
|
||||
|
||||
class RecordingTile extends StatelessWidget {
|
||||
final WorkInfo workInfo;
|
||||
final RecordingInfo recordingInfo;
|
||||
|
||||
RecordingTile({
|
||||
this.workInfo,
|
||||
this.recordingInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
DefaultTextStyle(
|
||||
style: textTheme.subtitle1,
|
||||
child: Text(workInfo.composers
|
||||
.map((p) => '${p.firstName} ${p.lastName}')
|
||||
.join(', ')),
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.headline6,
|
||||
child: Text(workInfo.work.title),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.bodyText1,
|
||||
child: PerformancesText(
|
||||
performanceInfos: recordingInfo.performances,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
common/lib/src/widgets/texts.dart
Normal file
73
common/lib/src/widgets/texts.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
/// A widget showing information on a list of performances.
|
||||
class PerformancesText extends StatelessWidget {
|
||||
/// The information to show.
|
||||
final List<PerformanceInfo> performanceInfos;
|
||||
|
||||
PerformancesText({
|
||||
this.performanceInfos,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> performanceTexts = [];
|
||||
|
||||
for (final p in performanceInfos) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (p.person != null) {
|
||||
buffer.write('${p.person.firstName} ${p.person.lastName}');
|
||||
} else if (p.ensemble != null) {
|
||||
buffer.write(p.ensemble.name);
|
||||
} else {
|
||||
buffer.write('Unknown');
|
||||
}
|
||||
|
||||
if (p.role != null) {
|
||||
buffer.write(' (${p.role.name})');
|
||||
}
|
||||
|
||||
performanceTexts.add(buffer.toString());
|
||||
}
|
||||
|
||||
return Text(performanceTexts.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
class WorkText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
WorkText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return StreamBuilder<Work>(
|
||||
stream: backend.db.workById(workId).watchSingle(),
|
||||
builder: (context, snapshot) => Text(snapshot.data?.title ?? '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ComposersText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
ComposersText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return StreamBuilder<List<Person>>(
|
||||
stream: backend.db.composersByWork(workId).watch(),
|
||||
builder: (context, snapshot) => Text(snapshot.hasData
|
||||
? snapshot.data.map((p) => '${p.firstName} ${p.lastName}').join(', ')
|
||||
: '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue