Move reusable code from mobile to common

This will be useful for a future desktop application.
This commit is contained in:
Elias Projahn 2020-05-04 21:49:44 +02:00
parent 6e1255f26e
commit 711b19c998
40 changed files with 813 additions and 581 deletions

221
common/lib/src/backend.dart Normal file
View file

@ -0,0 +1,221 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:moor/isolate.dart';
import 'package:moor/moor.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:musicus_client/musicus_client.dart';
import 'package:musicus_database/musicus_database.dart';
import 'library.dart';
import 'platform.dart';
import 'playback.dart';
import 'settings.dart';
/// Current status of the backend.
enum MusicusBackendStatus {
/// The backend is loading.
///
/// It is not allowed to call any methods on the backend in this state.
loading,
/// Required settings are missing.
///
/// Currently this only includes the music library path. It is not allowed to
/// call any methods on the backend in this state.
setup,
/// The backend is ready to be used.
///
/// This is the only state, in which it is allowed to call methods on the
/// backend.
ready,
}
/// Meta widget holding all backend ressources for Musicus.
///
/// This widget is intended to sit near the top of the widget tree. Widgets
/// below it can get the current backend state using the static [of] method.
/// The backend is intended to be used exactly once and live until the UI is
/// exited. Because of that, consuming widgets don't need to care about a
/// change of the backend state object.
///
/// The backend maintains a Musicus database within a Moor isolate. The connect
/// port will be registered as 'moor' in the [IsolateNameServer].
class MusicusBackend extends StatefulWidget {
/// Path to the database file.
final String dbPath;
/// An object to persist the settings.
final MusicusSettingsStorage settingsStorage;
/// An object handling playback.
final MusicusPlayback playback;
/// An object handling platform dependent functionality.
final MusicusPlatform platform;
/// The first child below the backend widget.
///
/// This widget should keep track of the current backend status and block
/// other widgets from accessing the backend until its status is set to
/// [MusicusBackendStatus.ready].
final Widget child;
MusicusBackend({
@required this.dbPath,
@required this.settingsStorage,
@required this.playback,
@required this.platform,
@required this.child,
});
@override
MusicusBackendState createState() => MusicusBackendState();
static MusicusBackendState of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_InheritedBackend>().state;
}
class MusicusBackendState extends State<MusicusBackend> {
/// Starts the Moor isolate.
///
/// It will create a database connection for [request.path] and will send the
/// Moor send port through [request.sendPort].
static void _moorIsolateEntrypoint(_IsolateStartRequest request) {
final executor = VmDatabase(File(request.path));
final moorIsolate =
MoorIsolate.inCurrent(() => DatabaseConnection.fromExecutor(executor));
request.sendPort.send(moorIsolate.connectPort);
}
/// The current backend status.
///
/// If this is not [MusicusBackendStatus.ready], the [child] widget should
/// prevent all access to the backend.
MusicusBackendStatus status = MusicusBackendStatus.loading;
Database db;
MusicusPlayback playback;
MusicusSettings settings;
MusicusClient client;
MusicusPlatform platform;
MusicusLibrary library;
@override
void initState() {
super.initState();
_load();
}
/// Initialize resources.
Future<void> _load() async {
SendPort moorPort = IsolateNameServer.lookupPortByName('moor');
if (moorPort == null) {
final receivePort = ReceivePort();
await Isolate.spawn(_moorIsolateEntrypoint,
_IsolateStartRequest(receivePort.sendPort, widget.dbPath));
moorPort = await receivePort.first;
IsolateNameServer.registerPortWithName(moorPort, 'moor');
}
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
db = Database.connect(await moorIsolate.connect());
playback = widget.playback;
await playback.setup();
settings = MusicusSettings(widget.settingsStorage);
await settings.load();
settings.musicLibraryPath.listen((path) {
setState(() {
status = MusicusBackendStatus.loading;
});
_updateMusicLibrary(path);
});
settings.server.listen((serverSettings) {
_updateClient(serverSettings);
});
_updateClient(settings.server.value);
final path = settings.musicLibraryPath.value;
platform = widget.platform;
platform.setBasePath(path);
// This will change the status for us.
_updateMusicLibrary(path);
}
/// Create a music library according to [path].
Future<void> _updateMusicLibrary(String path) async {
if (path == null) {
setState(() {
status = MusicusBackendStatus.setup;
});
} else {
platform.setBasePath(path);
library = MusicusLibrary(path, platform);
await library.load();
setState(() {
status = MusicusBackendStatus.ready;
});
}
}
/// Create a new client based on [settings].
void _updateClient(MusicusServerSettings settings) {
client?.dispose();
client = MusicusClient(
host: settings.host,
port: settings.port,
basePath: settings.apiPath,
);
}
@override
Widget build(BuildContext context) {
return _InheritedBackend(
child: widget.child,
state: this,
);
}
@override
void dispose() {
super.dispose();
settings.dispose();
/// We don't stop the Moor isolate, because it can be used elsewhere.
db.close();
client.dispose();
}
}
/// Bundles arguments for the moor isolate.
class _IsolateStartRequest {
final SendPort sendPort;
final String path;
_IsolateStartRequest(this.sendPort, this.path);
}
/// Helper widget passing the current backend state down the widget tree.
class _InheritedBackend extends InheritedWidget {
final Widget child;
final MusicusBackendState state;
_InheritedBackend({
@required this.child,
@required this.state,
}) : super(child: child);
@override
bool updateShouldNotify(_InheritedBackend old) => true;
}

View file

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
class EnsembleEditor extends StatefulWidget {
final Ensemble ensemble;
EnsembleEditor({
this.ensemble,
});
@override
_EnsembleEditorState createState() => _EnsembleEditorState();
}
class _EnsembleEditorState extends State<EnsembleEditor> {
final nameController = TextEditingController();
bool uploading = false;
@override
void initState() {
super.initState();
if (widget.ensemble != null) {
nameController.text = widget.ensemble.name;
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Ensemble'),
actions: <Widget>[
uploading
? Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
)
: FlatButton(
child: Text('DONE'),
onPressed: () async {
setState(() {
uploading = true;
});
final ensemble = Ensemble(
id: widget.ensemble?.id ?? generateId(),
name: nameController.text,
);
final success = await backend.client.putEnsemble(ensemble);
setState(() {
uploading = false;
});
if (success) {
Navigator.pop(context, ensemble);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Failed to upload'),
));
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Name',
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
class InstrumentEditor extends StatefulWidget {
final Instrument instrument;
InstrumentEditor({
this.instrument,
});
@override
_InstrumentEditorState createState() => _InstrumentEditorState();
}
class _InstrumentEditorState extends State<InstrumentEditor> {
final nameController = TextEditingController();
bool uploading = false;
@override
void initState() {
super.initState();
if (widget.instrument != null) {
nameController.text = widget.instrument.name;
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Instrument/Role'),
actions: <Widget>[
uploading
? Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
)
: FlatButton(
child: Text('DONE'),
onPressed: () async {
setState(() {
uploading = true;
});
final instrument = Instrument(
id: widget.instrument?.id ?? generateId(),
name: nameController.text,
);
final success =
await backend.client.putInstrument(instrument);
setState(() {
uploading = false;
});
if (success) {
Navigator.pop(context, instrument);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Failed to upload'),
));
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Name',
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../selectors/ensemble.dart';
import '../selectors/instruments.dart';
import '../selectors/person.dart';
class PerformanceEditor extends StatefulWidget {
final PerformanceInfo performanceInfo;
PerformanceEditor({
this.performanceInfo,
});
@override
_PerformanceEditorState createState() => _PerformanceEditorState();
}
class _PerformanceEditorState extends State<PerformanceEditor> {
Person person;
Ensemble ensemble;
Instrument role;
@override
void initState() {
super.initState();
if (widget.performanceInfo != null) {
person = widget.performanceInfo.person;
ensemble = widget.performanceInfo.ensemble;
role = widget.performanceInfo.role;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Edit performer'),
actions: <Widget>[
FlatButton(
child: Text('DONE'),
onPressed: () => Navigator.pop(
context,
PerformanceInfo(
person: person,
ensemble: ensemble,
role: role,
),
),
),
],
),
body: ListView(
children: <Widget>[
ListTile(
title: Text('Person'),
subtitle: Text(person != null
? '${person.firstName} ${person.lastName}'
: 'Select person'),
onTap: () async {
final Person newPerson = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonsSelector(),
fullscreenDialog: true,
),
);
if (newPerson != null) {
setState(() {
person = newPerson;
ensemble = null;
});
}
},
),
ListTile(
title: Text('Ensemble'),
subtitle: Text(ensemble?.name ?? 'Select ensemble'),
onTap: () async {
final Ensemble newEnsemble = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EnsembleSelector(),
fullscreenDialog: true,
),
);
if (newEnsemble != null) {
setState(() {
ensemble = newEnsemble;
person = null;
});
}
},
),
ListTile(
title: Text('Role'),
subtitle: Text(role?.name ?? 'Select instrument/role'),
trailing: role != null
? IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
role = null;
});
},
)
: null,
onTap: () async {
final Instrument newRole = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InstrumentsSelector(),
fullscreenDialog: true,
),
);
if (newRole != null) {
setState(() {
role = newRole;
});
}
},
),
],
),
);
}
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
class PersonEditor extends StatefulWidget {
final Person person;
PersonEditor({
this.person,
});
@override
_PersonEditorState createState() => _PersonEditorState();
}
class _PersonEditorState extends State<PersonEditor> {
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
bool uploading = false;
@override
void initState() {
super.initState();
if (widget.person != null) {
firstNameController.text = widget.person.firstName;
lastNameController.text = widget.person.lastName;
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Person'),
actions: <Widget>[
uploading
? Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
)
: FlatButton(
child: Text('DONE'),
onPressed: () async {
setState(() {
uploading = true;
});
final person = Person(
id: widget.person?.id ?? generateId(),
firstName: firstNameController.text,
lastName: lastNameController.text,
);
final success = await backend.client.putPerson(person);
setState(() {
uploading = false;
});
if (success) {
Navigator.pop(context, person);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Failed to upload'),
));
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: firstNameController,
decoration: InputDecoration(
labelText: 'First name',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: lastNameController,
decoration: InputDecoration(
labelText: 'Last name',
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../editors/performance.dart';
import '../selectors/recording.dart';
import '../selectors/work.dart';
/// Screen for editing a recording.
///
/// If the user has finished editing, the result will be returned using the
/// navigator as a [RecordingSelectorResult] object.
class RecordingEditor extends StatefulWidget {
/// The recording to edit.
///
/// If this is null, a new recording will be created.
final RecordingInfo recordingInfo;
RecordingEditor({
this.recordingInfo,
});
@override
_RecordingEditorState createState() => _RecordingEditorState();
}
class _RecordingEditorState extends State<RecordingEditor> {
final commentController = TextEditingController();
bool uploading = false;
WorkInfo workInfo;
List<PerformanceInfo> performanceInfos = [];
@override
void initState() {
super.initState();
if (widget.recordingInfo != null) {
final backend = MusicusBackend.of(context);
() async {
workInfo = await backend.db.getWork(widget.recordingInfo.recording.id);
performanceInfos = List.from(widget.recordingInfo.performances);
}();
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
Future<void> selectWork() async {
final WorkInfo newWorkInfo = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WorkSelector(),
fullscreenDialog: true,
));
if (newWorkInfo != null) {
setState(() {
workInfo = newWorkInfo;
});
}
}
final List<Widget> performanceTiles = [];
for (var i = 0; i < performanceInfos.length; i++) {
final p = performanceInfos[i];
performanceTiles.add(ListTile(
title: Text(p.person != null
? '${p.person.firstName} ${p.person.lastName}'
: p.ensemble.name),
subtitle: p.role != null ? Text(p.role.name) : null,
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
performanceInfos.remove(p);
});
},
),
onTap: () async {
final PerformanceInfo performanceInfo = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PerformanceEditor(
performanceInfo: p,
),
fullscreenDialog: true,
));
if (performanceInfo != null) {
setState(() {
performanceInfos[i] = performanceInfo;
});
}
},
));
}
return Scaffold(
appBar: AppBar(
title: Text('Recording'),
actions: <Widget>[
uploading
? Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
)
: FlatButton(
child: Text('DONE'),
onPressed: () async {
setState(() {
uploading = true;
});
final recordingInfo = RecordingInfo(
recording: Recording(
id: widget?.recordingInfo?.recording?.id ??
generateId(),
work: workInfo.work.id,
comment: commentController.text,
),
performances: performanceInfos,
);
final success =
await backend.client.putRecording(recordingInfo);
setState(() {
uploading = false;
});
if (success) {
Navigator.pop(
context,
RecordingSelectorResult(
workInfo: workInfo,
recordingInfo: recordingInfo,
),
);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Failed to upload'),
));
}
},
),
],
),
body: ListView(
children: <Widget>[
workInfo != null
? ListTile(
title: Text(workInfo.work.title),
subtitle: Text(workInfo.composers
.map((p) => '${p.firstName} ${p.lastName}')
.join(', ')),
onTap: selectWork,
)
: ListTile(
title: Text('Work'),
subtitle: Text('Select work'),
onTap: selectWork,
),
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 0.0,
bottom: 16.0,
),
child: TextField(
controller: commentController,
decoration: InputDecoration(
labelText: 'Comment',
),
),
),
ListTile(
title: Text('Performers'),
trailing: IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
final PerformanceInfo model = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PerformanceEditor(),
fullscreenDialog: true,
));
if (model != null) {
setState(() {
performanceInfos.add(model);
});
}
},
),
),
...performanceTiles,
],
),
);
}
}

View file

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../library.dart';
import '../selectors/files.dart';
import '../selectors/recording.dart';
import '../widgets/recording_tile.dart';
class TrackModel {
int workPartIndex;
String workPartTitle;
String fileName;
TrackModel(this.fileName);
}
class TracksEditor extends StatefulWidget {
@override
_TracksEditorState createState() => _TracksEditorState();
}
class _TracksEditorState extends State<TracksEditor> {
MusicusBackendState backend;
WorkInfo workInfo;
RecordingInfo recordingInfo;
String parentId;
List<TrackModel> trackModels = [];
@override
Widget build(BuildContext context) {
backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Tracks'),
actions: <Widget>[
FlatButton(
child: Text('DONE'),
onPressed: () async {
final List<Track> tracks = [];
for (var i = 0; i < trackModels.length; i++) {
final trackModel = trackModels[i];
tracks.add(Track(
fileName: trackModel.fileName,
recordingId: recordingInfo.recording.id,
index: i,
partIds: [trackModel.workPartIndex],
));
}
// We need to copy all information associated with this track we
// got by asking the server to our local database. For now, we
// will just override everything that we already had previously.
backend.db.updateWork(workInfo);
backend.db.updateRecording(recordingInfo);
backend.library.addTracks(parentId, tracks);
Navigator.pop(context);
},
),
],
),
body: ReorderableListView(
header: Column(
children: <Widget>[
ListTile(
title: recordingInfo != null
? RecordingTile(
workInfo: workInfo,
recordingInfo: recordingInfo,
)
: Text('Select recording'),
onTap: selectRecording,
),
ListTile(
title: Text('Files'),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () async {
final FilesSelectorResult result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FilesSelector(),
),
);
if (result != null) {
final List<TrackModel> newTrackModels = [];
for (final document in result.selection) {
newTrackModels.add(TrackModel(document.name));
}
setState(() {
parentId = result.parentId;
trackModels = newTrackModels;
});
if (recordingInfo != null) {
updateAutoParts();
}
}
},
),
),
],
),
children: trackModels
.map((t) => ListTile(
key: Key(t.hashCode.toString()),
leading: const Icon(Icons.drag_handle),
title: Text(t.workPartTitle ?? 'Set work part'),
subtitle: Text(t.fileName),
))
.toList(),
onReorder: (i1, i2) {
setState(() {
final track = trackModels.removeAt(i1);
final newIndex = i2 > i1 ? i2 - 1 : i2;
trackModels.insert(newIndex, track);
});
},
),
);
}
Future<void> selectRecording() async {
final RecordingSelectorResult result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RecordingSelector(),
),
);
if (result != null) {
setState(() {
workInfo = result.workInfo;
recordingInfo = result.recordingInfo;
});
updateAutoParts();
}
}
/// Automatically associate the tracks with work parts.
Future<void> updateAutoParts() async {
setState(() {
for (var i = 0; i < trackModels.length; i++) {
if (i >= workInfo.parts.length) {
trackModels[i].workPartIndex = null;
trackModels[i].workPartTitle = null;
} else {
trackModels[i].workPartIndex = workInfo.parts[i].work.partIndex;
trackModels[i].workPartTitle = workInfo.parts[i].work.title;
}
}
});
}
}

View file

@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../selectors/instruments.dart';
import '../selectors/person.dart';
class PartData {
final titleController = TextEditingController();
Person composer;
List<Instrument> instruments;
PartData({
String title,
this.composer,
this.instruments = const [],
}) {
titleController.text = title ?? '';
}
}
class WorkProperties extends StatelessWidget {
final TextEditingController titleController;
final Person composer;
final List<Instrument> instruments;
final void Function(Person) onComposerChanged;
final void Function(List<Instrument>) onInstrumentsChanged;
WorkProperties({
@required this.titleController,
@required this.composer,
@required this.instruments,
@required this.onComposerChanged,
@required this.onInstrumentsChanged,
});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: titleController,
decoration: InputDecoration(
labelText: 'Title',
),
),
),
ListTile(
title: Text('Composer'),
subtitle: Text(composer != null
? '${composer.firstName} ${composer.lastName}'
: 'Select composer'),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
onComposerChanged(null);
},
),
onTap: () async {
final Person person = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonsSelector(),
fullscreenDialog: true,
));
if (person != null) {
onComposerChanged(person);
}
},
),
ListTile(
title: Text('Instruments'),
subtitle: Text(instruments.isNotEmpty
? instruments.map((i) => i.name).join(', ')
: 'Select instruments'),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
onInstrumentsChanged([]);
}),
onTap: () async {
final List<Instrument> selection = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InstrumentsSelector(
multiple: true,
selection: instruments,
),
fullscreenDialog: true,
));
if (selection != null) {
onInstrumentsChanged(selection);
}
},
),
],
);
}
}
class PartTile extends StatefulWidget {
final PartData part;
final void Function() onMore;
final void Function() onDelete;
PartTile({
Key key,
@required this.part,
@required this.onMore,
@required this.onDelete,
}) : super(key: key);
@override
_PartTileState createState() => _PartTileState();
}
class _PartTileState extends State<PartTile> {
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 8.0),
child: Icon(
Icons.drag_handle,
),
),
Expanded(
child: TextField(
controller: widget.part.titleController,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Part title',
),
),
),
IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: widget.onMore,
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: widget.onDelete,
),
],
);
}
}
/// Screen for editing a work.
///
/// If the user is finished editing, the result will be returned as a [WorkInfo]
/// object.
class WorkEditor extends StatefulWidget {
/// The work to edit.
///
/// If this is null, a new work will be created.
final WorkInfo workInfo;
WorkEditor({
this.workInfo,
});
@override
_WorkEditorState createState() => _WorkEditorState();
}
class _WorkEditorState extends State<WorkEditor> {
final titleController = TextEditingController();
bool uploading = false;
Person composer;
List<Instrument> instruments = [];
List<PartData> parts = [];
@override
void initState() {
super.initState();
if (widget.workInfo != null) {
titleController.text = widget.workInfo.work.title;
// TODO: Theoretically this includes the composers of all parts.
composer = widget.workInfo.composers.first;
instruments = List.from(widget.workInfo.instruments);
for (final partInfo in widget.workInfo.parts) {
parts.add(PartData(
title: partInfo.work.title,
composer: partInfo.composer,
instruments: List.from(partInfo.instruments),
));
}
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
final List<Widget> partTiles = [];
for (var i = 0; i < parts.length; i++) {
final part = parts[i];
partTiles.add(PartTile(
key: Key(part.hashCode.toString()),
part: part,
onMore: () {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => Dialog(
child: ListView(
shrinkWrap: true,
children: <Widget>[
WorkProperties(
titleController: part.titleController,
composer: part.composer,
instruments: part.instruments,
onComposerChanged: (composer) {
setState(() {
part.composer = composer;
});
},
onInstrumentsChanged: (instruments) {
setState(() {
part.instruments = instruments;
});
},
),
],
),
),
),
);
},
onDelete: () {
setState(() {
parts.removeAt(i);
});
},
));
}
return Scaffold(
appBar: AppBar(
title: Text('Work'),
actions: <Widget>[
uploading
? Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
)
: FlatButton(
child: Text('DONE'),
onPressed: () async {
setState(() {
uploading = true;
});
final workId = widget?.workInfo?.work?.id ?? generateId();
List<PartInfo> partInfos = [];
for (var i = 0; i < parts.length; i++) {
final part = parts[i];
partInfos.add(PartInfo(
work: Work(
id: generateId(),
title: part.titleController.text,
composer: part.composer?.id,
partOf: workId,
partIndex: i,
),
instruments: part.instruments,
composer: part.composer,
));
}
final workInfo = WorkInfo(
work: Work(
id: workId,
title: titleController.text,
composer: composer?.id,
),
instruments: instruments,
// TODO: Theoretically, this should include all composers
// from the parts.
composers: [composer],
parts: partInfos,
);
final success = await backend.client.putWork(workInfo);
setState(() {
uploading = false;
});
if (success) {
Navigator.pop(context, workInfo);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Failed to upload'),
));
}
},
),
],
),
body: ReorderableListView(
header: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
WorkProperties(
titleController: titleController,
composer: composer,
instruments: instruments,
onComposerChanged: (newComposer) {
setState(() {
composer = newComposer;
});
},
onInstrumentsChanged: (newInstruments) {
setState(() {
instruments = newInstruments;
});
},
),
if (parts.length > 0)
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Text(
'Parts',
style: Theme.of(context).textTheme.subtitle1,
),
),
],
),
children: partTiles,
onReorder: (i1, i2) {
setState(() {
final part = parts.removeAt(i1);
final newIndex = i2 > i1 ? i2 - 1 : i2;
parts.insert(newIndex, part);
});
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text('Add part'),
onPressed: () {
setState(() {
parts.add(PartData());
});
},
),
);
}
}

187
common/lib/src/library.dart Normal file
View file

@ -0,0 +1,187 @@
import 'dart:convert';
import 'platform.dart';
/// Bundles a [Track] with information on how to find the corresponding file.
class InternalTrack {
/// The represented track.
final Track track;
/// A string identifying the track for playback.
///
/// This will be the result of calling the platform objects getIdentifier()
/// function with the file name of the track.
final String identifier;
InternalTrack({
this.track,
this.identifier,
});
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
track: Track.fromJson(json['track']),
identifier: json['identifier'],
);
Map<String, dynamic> toJson() => {
'track': track.toJson(),
'identifier': identifier,
};
}
/// Description of a concrete audio file.
///
/// This gets stored in the folder of the audio file and links the audio file
/// to a recording in the database.
class Track {
/// The name of the file that contains the track's audio.
///
/// This corresponds to a document ID in terms of the Android Storage Access
/// Framework.
final String fileName;
/// Index within the list of tracks for the corresponding recording.
final int index;
/// Of which recording this track is a part of.
final int recordingId;
/// Which work parts of the recorded work are contained in this track.
final List<int> partIds;
Track({
this.fileName,
this.index,
this.recordingId,
this.partIds,
});
factory Track.fromJson(Map<String, dynamic> json) => Track(
fileName: json['fileName'],
index: json['index'],
recordingId: json['recording'],
partIds: List.from(json['parts']),
);
Map<String, dynamic> toJson() => {
'fileName': fileName,
'index': index,
'recording': recordingId,
'parts': partIds,
};
}
/// Representation of all tracked audio files in one folder.
class MusicusFile {
/// Current version of the Musicus file format.
///
/// If incompatible changes are made, this will be increased by one.
static const currentVersion = 0;
/// Musicus file format version in use.
///
/// This will be used in the future, if incompatible changes are made.
final int version;
/// List of [Track] objects.
final List<Track> tracks;
MusicusFile({
this.version = currentVersion,
List<Track> tracks,
}) : tracks = tracks ?? [];
factory MusicusFile.fromJson(Map<String, dynamic> json) => MusicusFile(
version: json['version'],
tracks: json['tracks']
.map<Track>((trackJson) => Track.fromJson(trackJson))
.toList(growable: true),
);
Map<String, dynamic> toJson() => {
'version': version,
'tracks': tracks.map((t) => t.toJson()).toList(),
};
}
/// Manager for all available tracks and their representation on disk.
class MusicusLibrary {
/// String representing the music library base path.
final String basePath;
/// Access to platform dependent functionality.
final MusicusPlatform platform;
/// Map of all available tracks by recording ID.
///
/// These are [InternalTrack] objects to store the URI of the corresponding
/// audio file alongside the real [Track] object.
final Map<int, List<InternalTrack>> tracks = {};
MusicusLibrary(this.basePath, this.platform);
/// Load all available tracks.
///
/// This recursively searches through the whole music library, reads the
/// content of all files called musicus.json and stores all track information
/// that it found.
Future<void> load() async {
// TODO: Consider capping the recursion somewhere.
Future<void> recurse([String parentId]) async {
final children = await platform.getChildren(parentId);
for (final child in children) {
if (child.isDirectory) {
recurse(child.id);
} else if (child.name == 'musicus.json') {
final content = await platform.readDocument(child.id);
final musicusFile = MusicusFile.fromJson(jsonDecode(content));
for (final track in musicusFile.tracks) {
_indexTrack(parentId, track);
}
}
}
}
await recurse();
}
/// Add a list of new tracks to the music library.
///
/// They are stored in this instance and on disk in the directory denoted by
/// [parentId].
Future<void> addTracks(String parentId, List<Track> newTracks) async {
MusicusFile musicusFile;
final oldContent =
await platform.readDocumentByName(parentId, 'musicus.json');
if (oldContent != null) {
musicusFile = MusicusFile.fromJson(jsonDecode(oldContent));
} else {
musicusFile = MusicusFile();
}
for (final track in newTracks) {
_indexTrack(parentId, track);
musicusFile.tracks.add(track);
}
await platform.writeDocumentByName(
parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
}
/// Add a track to the map of available tracks.
Future<void> _indexTrack(String parentId, Track track) async {
final iTrack = InternalTrack(
track: track,
identifier: await platform.getIdentifier(parentId, track.fileName),
);
if (tracks.containsKey(track.recordingId)) {
tracks[track.recordingId].add(iTrack);
} else {
tracks[track.recordingId] = [iTrack];
}
}
}

View file

@ -0,0 +1,76 @@
/// Object representing a document in Storage Access Framework terms.
class Document {
/// Unique ID for the document.
///
/// The platform implementation thould be able to get the content of the
/// document based on this value.
final String id;
/// Name of the document (i.e. file name).
final String name;
/// Document ID of the parent document.
final String parent;
/// Whether this document represents a directory.
final bool isDirectory;
Document({
this.id,
this.name,
this.parent,
this.isDirectory,
});
// Use Map<dynamic, dynamic> here, as we get casting errors otherwise. This
// won't be typesafe anyway.
Document.fromJson(Map<dynamic, dynamic> json)
: id = json['id'],
name = json['name'],
parent = json['parent'],
isDirectory = json['isDirectory'];
}
/// Platform dependent code for the Musicus backend.
abstract class MusicusPlatform {
/// An identifier for the root directory of the music library.
///
/// This will be the string, that is stored as musicLibraryPath in the
/// settings object.
String basePath;
MusicusPlatform();
/// This will be called, when the music library path was changed.
void setBasePath(String path) {
basePath = path;
}
/// Get all documents in a directory.
///
/// [parentId] will be the ID of the directory document. If [parentId] is
/// null, the children of the root directory will be returned.
Future<List<Document>> getChildren(String parentId);
/// Read the contents of a document by ID.
Future<String> readDocument(String id);
/// Read from a document by name.
///
/// [parentId] is the document ID of the parent directory.
Future<String> readDocumentByName(String parentId, String fileName);
/// Get a string identifying a document.
///
/// [parentId] is the document ID of the parent directory. The return value
/// should be a string, that the playback object can use to find and play the
/// file. It will be included in [InternalTrack] objects by the music
/// library.
Future<String> getIdentifier(String parentId, String fileName);
/// Write to a document by name.
///
/// [parentId] is the document ID of the parent directory.
Future<void> writeDocumentByName(
String parentId, String fileName, String contents);
}

View file

@ -0,0 +1,96 @@
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'library.dart';
/// Base class for Musicus playback.
abstract class MusicusPlayback {
/// Whether the player is active.
///
/// This means, that there is at least one item in the queue and the playback
/// service is ready to play.
final active = BehaviorSubject.seeded(false);
/// The current playlist.
///
/// If the player is not active, this will be an empty list.
final playlist = BehaviorSubject.seeded(<InternalTrack>[]);
/// Index of the currently played (or paused) track within the playlist.
///
/// This will be zero, if the player is not active!
final currentIndex = BehaviorSubject.seeded(0);
/// The currently played track.
///
/// This will be null, if there is no current track.
final currentTrack = BehaviorSubject<InternalTrack>.seeded(null);
/// Whether we are currently playing or not.
///
/// This will be false, if the player is not active.
final playing = BehaviorSubject.seeded(false);
/// Current playback position.
///
/// If the player is not active, this will default to zero.
final position = BehaviorSubject.seeded(const Duration());
/// Duration of the current track.
///
/// If the player is not active, the duration will default to 1 s.
final duration = BehaviorSubject.seeded(const Duration(seconds: 1));
/// Playback position normalized to the range from zero to one.
final normalizedPosition = BehaviorSubject.seeded(0.0);
/// Initialize the player.
///
/// This will be called after the database was initialized.
Future<void> setup();
/// Add a list of tracks to the players playlist.
Future<void> addTracks(List<InternalTrack> tracks);
/// Remove the track at [index] from the playlist.
Future<void> removeTrack(int index);
/// Toggle whether the player is playing or paused.
Future<void> playPause();
/// Seek to [pos], which is a value between (and including) zero and one.
Future<void> seekTo(double pos);
/// Skip to the previous track in the playlist.
Future<void> skipToPrevious();
/// Play the next track in the playlist.
Future<void> skipToNext();
/// Switch to the track with the index [index] in the playlist.
Future<void> skipTo(int index);
/// Set all values to their default.
void reset() {
active.add(false);
playlist.add([]);
currentTrack.add(null);
playing.add(false);
position.add(const Duration());
duration.add(const Duration(seconds: 1));
normalizedPosition.add(0.0);
}
/// Tidy up.
@mustCallSuper
void dispose() {
active.close();
playlist.close();
currentIndex.close();
currentTrack.close();
playing.close();
position.close();
duration.close();
normalizedPosition.close();
}
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../editors/ensemble.dart';
import '../widgets/lists.dart';
/// A screen to select an ensemble.
///
/// If the user has selected one, it will be returned as an [Ensemble] object
/// using the navigator.
class EnsembleSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Select ensemble'),
),
body: EnsemblesList(
onSelected: (ensemble) {
Navigator.pop(context, ensemble);
},
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final Ensemble ensemble = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EnsembleEditor(),
fullscreenDialog: true,
),
);
if (ensemble != null) {
Navigator.pop(context, ensemble);
}
},
),
);
}
}

View file

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import '../backend.dart';
import '../platform.dart';
/// Result of the user's interaction with the files selector.
///
/// This will be given back when popping the navigator.
class FilesSelectorResult {
/// Document ID of the parent directory of the selected files.
///
/// This will be null, if they are in the toplevel directory.
final String parentId;
/// Selected files.
final Set<Document> selection;
FilesSelectorResult(this.parentId, this.selection);
}
class FilesSelector extends StatefulWidget {
@override
_FilesSelectorState createState() => _FilesSelectorState();
}
class _FilesSelectorState extends State<FilesSelector> {
MusicusBackendState backend;
List<Document> history = [];
List<Document> children = [];
Set<Document> selection = {};
@override
void didChangeDependencies() {
super.didChangeDependencies();
backend = MusicusBackend.of(context);
loadChildren();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
child: Scaffold(
appBar: AppBar(
title: Text('Choose files'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.pop(context);
},
),
actions: <Widget>[
FlatButton(
child: Text('DONE'),
onPressed: () {
Navigator.pop(
context,
FilesSelectorResult(
history.isNotEmpty ? history.last.id : null,
selection,
),
);
},
),
],
),
body: Column(
children: <Widget>[
Material(
elevation: 2.0,
child: ListTile(
leading: IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: history.isNotEmpty ? up : null,
),
title: Text(
history.isNotEmpty ? history.last.name : 'Music library'),
),
),
Expanded(
child: ListView.builder(
itemCount: children.length,
itemBuilder: (context, index) {
final document = children[index];
if (document.isDirectory) {
return ListTile(
leading: const Icon(Icons.folder),
title: Text(document.name),
onTap: () {
setState(() {
history.add(document);
});
loadChildren();
},
);
} else {
return CheckboxListTile(
controlAffinity: ListTileControlAffinity.trailing,
secondary: const Icon(Icons.insert_drive_file),
title: Text(document.name),
value: selection.contains(document),
onChanged: (selected) {
setState(() {
if (selected) {
selection.add(document);
} else {
selection.remove(document);
}
});
},
);
}
},
),
),
],
),
),
onWillPop: () => Future.value(up()),
);
}
Future<void> loadChildren() async {
setState(() {
children = [];
// We reset the selection here, because the user should not be able to
// select files from multiple directories for now.
selection = {};
});
final newChildren = await backend.platform
.getChildren(history.isNotEmpty ? history.last.id : null);
newChildren.sort((d1, d2) {
if (d1.isDirectory != d2.isDirectory) {
return d1.isDirectory ? -1 : 1;
} else {
return d1.name.compareTo(d2.name);
}
});
setState(() {
children = newChildren;
});
}
bool up() {
if (history.isNotEmpty) {
setState(() {
history.removeLast();
});
loadChildren();
return false;
} else {
return true;
}
}
}

View file

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
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;
final List<Instrument> selection;
InstrumentsSelector({
this.multiple = false,
this.selection,
});
@override
_InstrumentsSelectorState createState() => _InstrumentsSelectorState();
}
class _InstrumentsSelectorState extends State<InstrumentsSelector> {
final _list = GlobalKey<PagedListViewState<Instrument>>();
Set<Instrument> selection = {};
String _search;
@override
void initState() {
super.initState();
if (widget.selection != null) {
selection = widget.selection.toSet();
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.multiple
? 'Select instruments/roles'
: 'Select instrument/role'),
actions: widget.multiple
? <Widget>[
FlatButton(
child: Text('DONE'),
onPressed: () => Navigator.pop(context, selection.toList()),
),
]
: null,
),
body: 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<Instrument>(
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),
value: selection.contains(instrument),
checkColor: Colors.black,
onChanged: (selected) {
setState(() {
if (selected) {
selection.add(instrument);
} else {
selection.remove(instrument);
}
});
},
);
} else {
return ListTile(
title: Text(instrument.name),
onTap: () => Navigator.pop(context, instrument),
);
}
},
),
),
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final Instrument instrument = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InstrumentEditor(),
fullscreenDialog: true,
));
if (instrument != null) {
if (widget.multiple) {
setState(() {
selection.add(instrument);
});
// We need to rebuild the list view, because we added an item.
_list.currentState.update();
} else {
Navigator.pop(context, instrument);
}
}
},
),
);
}
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../editors/person.dart';
import '../widgets/lists.dart';
/// A screen to select a person.
///
/// If the user has selected a person, it will be returned as a [Person] object
/// using the navigator.
class PersonsSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Select person'),
),
body: PersonsList(
onSelected: (person) {
Navigator.pop(context, person);
},
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final Person person = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonEditor(),
fullscreenDialog: true,
),
);
if (person != null) {
Navigator.pop(context, person);
}
},
),
);
}
}

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../editors/recording.dart';
import '../widgets/lists.dart';
class RecordingSelectorResult {
final WorkInfo workInfo;
final RecordingInfo recordingInfo;
RecordingSelectorResult({
this.workInfo,
this.recordingInfo,
});
}
/// A screen to select a recording.
///
/// If the user has selected a recording, a [RecordingSelectorResult] containing
/// the selected recording and the recorded work will be returned using the
/// navigator.
class RecordingSelector extends StatefulWidget {
@override
_RecordingSelectorState createState() => _RecordingSelectorState();
}
class _RecordingSelectorState extends State<RecordingSelector> {
Person person;
WorkInfo workInfo;
@override
Widget build(BuildContext context) {
Widget body;
if (person == null) {
body = PersonsList(
onSelected: (newPerson) {
setState(() {
person = newPerson;
});
},
);
} else if (workInfo == null) {
body = WorksList(
personId: person.id,
onSelected: (newWorkInfo) {
setState(() {
workInfo = newWorkInfo;
});
},
);
} else {
body = RecordingsList(
workId: workInfo.work.id,
onSelected: (recordingInfo) {
Navigator.pop(
context,
RecordingSelectorResult(
workInfo: workInfo,
recordingInfo: recordingInfo,
),
);
},
);
}
return Scaffold(
appBar: AppBar(
title: Text('Select recording'),
),
body: body,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final RecordingSelectorResult result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RecordingEditor(),
fullscreenDialog: true,
),
);
if (result != null) {
Navigator.pop(context, result);
}
},
),
);
}
}

View file

@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart';
import '../editors/work.dart';
import '../widgets/lists.dart';
/// A screen to select a work.
///
/// If the user has selected a work, a [WorkInfo] will be returned
/// using the navigator.
class WorkSelector extends StatefulWidget {
@override
_WorkSelectorState createState() => _WorkSelectorState();
}
class _WorkSelectorState extends State<WorkSelector> {
Person person;
@override
Widget build(BuildContext context) {
Widget body;
if (person == null) {
body = PersonsList(
onSelected: (newPerson) {
setState(() {
person = newPerson;
});
},
);
} else {
body = WorksList(
personId: person.id,
onSelected: (workInfo) {
setState(() {
Navigator.pop(context, workInfo);
});
},
);
}
return Scaffold(
appBar: AppBar(
title: Text('Select work'),
),
body: body,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
final WorkInfo workInfo = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WorkEditor(),
fullscreenDialog: true,
),
);
if (workInfo != null) {
Navigator.pop(context, workInfo);
}
},
),
);
}
}

View file

@ -0,0 +1,112 @@
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
/// Interface for persisting settings.
///
/// The methods should return null, if there is no value associated with the
/// provided key.
abstract class MusicusSettingsStorage {
Future<void> load();
Future<int> getInt(String key);
Future<String> getString(String key);
Future<void> setInt(String key, int value);
Future<void> setString(String key, String value);
}
/// Settings concerning the Musicus server to connect to.
///
/// We don't support setting a scheme here, because there may be password being
/// submitted in the future, so we default to HTTPS.
class MusicusServerSettings {
/// Host to connect to, e.g. 'musicus.johrpan.de';
final String host;
/// Port to connect to.
final int port;
/// Path to the API.
///
/// This can be either null or empty, if the API is at the root of the host.
final String apiPath;
MusicusServerSettings({
@required this.host,
@required this.port,
@required this.apiPath,
});
}
/// Manager for all settings that are persisted.
class MusicusSettings {
static const defaultHost = 'musicus.johrpan.de';
static const defaultPort = 443;
static const defaultApiPath = '/api';
/// The storage method to use.
final MusicusSettingsStorage storage;
/// A identifier for the base path of the music library.
///
/// This could be a file path on destop systems or a tree URI in terms of the
/// Android storage access framework.
final musicLibraryPath = BehaviorSubject<String>();
/// Musicus server to connect to.
final server = BehaviorSubject<MusicusServerSettings>();
/// Create a settings instance.
MusicusSettings(this.storage);
/// Initialize the settings.
Future<void> load() async {
await storage.load();
final path = await storage.getString('musicLibraryPath');
if (path != null) {
musicLibraryPath.add(path);
}
final host = await storage.getString('serverHost') ?? defaultHost;
final port = await storage.getInt('serverPort') ?? defaultPort;
final apiPath = await storage.getString('serverApiPath') ?? defaultApiPath;
server.add(MusicusServerSettings(
host: host,
port: port,
apiPath: apiPath,
));
}
/// Set a new music library path.
///
/// This will persist the new value and update the stream.
Future<void> setMusicLibraryPath(String path) async {
await storage.setString('musicLibraryPath', path);
musicLibraryPath.add(path);
}
/// Update the server settings.
///
/// This will persist the new values and update the stream.
Future<void> setServer(MusicusServerSettings serverSettings) async {
await storage.setString('serverHost', serverSettings.host);
await storage.setInt('serverPort', serverSettings.port);
await storage.setString('severApiPath', serverSettings.apiPath);
server.add(serverSettings);
}
/// Reset the server settings to their defaults.
Future<void> resetServer() async {
await setServer(MusicusServerSettings(
host: defaultHost,
port: defaultPort,
apiPath: defaultApiPath,
));
}
/// Tidy up.
void dispose() {
musicLibraryPath.close();
server.close();
}
}

View 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);
}
},
),
);
}
}

View 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,
),
),
],
),
);
}
}

View 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(', ')
: '...'),
);
}
}