diff --git a/mobile/lib/editors/ensemble.dart b/mobile/lib/editors/ensemble.dart index 2ac1ba3..0556062 100644 --- a/mobile/lib/editors/ensemble.dart +++ b/mobile/lib/editors/ensemble.dart @@ -42,7 +42,7 @@ class _EnsembleEditorState extends State { name: nameController.text, ); - await backend.db.updateEnsemble(ensemble); + await backend.client.putEnsemble(ensemble); Navigator.pop(context, ensemble); }, ) diff --git a/mobile/lib/editors/instrument.dart b/mobile/lib/editors/instrument.dart index 6893bc8..f67c361 100644 --- a/mobile/lib/editors/instrument.dart +++ b/mobile/lib/editors/instrument.dart @@ -42,7 +42,7 @@ class _InstrumentEditorState extends State { name: nameController.text, ); - await backend.db.updateInstrument(instrument); + await backend.client.putInstrument(instrument); Navigator.pop(context, instrument); }, ) diff --git a/mobile/lib/editors/performance.dart b/mobile/lib/editors/performance.dart new file mode 100644 index 0000000..4e78380 --- /dev/null +++ b/mobile/lib/editors/performance.dart @@ -0,0 +1,119 @@ +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 { + 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: [ + FlatButton( + child: Text('DONE'), + onPressed: () => Navigator.pop( + context, + PerformanceInfo( + person: person, + ensemble: ensemble, + role: role, + ), + ), + ), + ], + ), + body: ListView( + children: [ + 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; + }); + } + }, + ), + 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; + }); + } + }, + ), + ListTile( + title: Text('Role'), + subtitle: Text(role?.name ?? 'Select instrument/role'), + onTap: () async { + final Instrument newRole = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InstrumentsSelector(), + fullscreenDialog: true, + ), + ); + + if (newRole != null) { + setState(() { + role = newRole; + }); + } + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/editors/person.dart b/mobile/lib/editors/person.dart index 1cb5170..a0bfcee 100644 --- a/mobile/lib/editors/person.dart +++ b/mobile/lib/editors/person.dart @@ -45,7 +45,7 @@ class _PersonEditorState extends State { lastName: lastNameController.text, ); - await backend.db.updatePerson(person); + await backend.client.putPerson(person); Navigator.pop(context, person); }, ), diff --git a/mobile/lib/editors/recording.dart b/mobile/lib/editors/recording.dart index 4934cf5..5c5c05a 100644 --- a/mobile/lib/editors/recording.dart +++ b/mobile/lib/editors/recording.dart @@ -2,10 +2,14 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; import '../backend.dart'; -import '../selectors/performer.dart'; +import '../editors/performance.dart'; +import '../selectors/recording.dart'; import '../selectors/work.dart'; -import '../widgets/texts.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 { final Recording recording; @@ -20,8 +24,8 @@ class RecordingEditor extends StatefulWidget { class _RecordingEditorState extends State { final commentController = TextEditingController(); - Work work; - List performanceModels = []; + WorkInfo workInfo; + List performanceInfos = []; @override void initState() { @@ -37,20 +41,56 @@ class _RecordingEditorState extends State { final backend = Backend.of(context); Future selectWork() async { - final Work newWork = await Navigator.push( + final WorkInfo newWorkInfo = await Navigator.push( context, MaterialPageRoute( builder: (context) => WorkSelector(), fullscreenDialog: true, )); - if (newWork != null) { + if (newWorkInfo != null) { setState(() { - work = newWork; + workInfo = newWorkInfo; }); } } + final List 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'), @@ -60,11 +100,11 @@ class _RecordingEditorState extends State { onPressed: () async { final recording = Recording( id: widget.recording?.id ?? generateId(), - work: work.id, + work: workInfo.work.id, comment: commentController.text, ); - final performances = performanceModels + final performances = performanceInfos .map((m) => Performance( recording: recording.id, person: m.person?.id, @@ -73,22 +113,28 @@ class _RecordingEditorState extends State { )) .toList(); - await backend.db.updateRecording(RecordingData( + final recordingInfo = + await backend.client.putRecording(RecordingData( recording: recording, performances: performances, )); - Navigator.pop(context, recording); + Navigator.pop(context, RecordingSelectorResult( + workInfo: workInfo, + recordingInfo: recordingInfo, + )); }, ) ], ), body: ListView( children: [ - work != null + workInfo != null ? ListTile( - title: WorkText(work.id), - subtitle: ComposersText(work.id), + title: Text(workInfo.work.title), + subtitle: Text(workInfo.composers + .map((p) => '${p.firstName} ${p.lastName}') + .join(', ')), onTap: selectWork, ) : ListTile( @@ -115,37 +161,22 @@ class _RecordingEditorState extends State { trailing: IconButton( icon: const Icon(Icons.add), onPressed: () async { - final PerformanceModel model = await Navigator.push( + final PerformanceInfo model = await Navigator.push( context, MaterialPageRoute( - builder: (context) => PerformerSelector(), + builder: (context) => PerformanceEditor(), fullscreenDialog: true, )); if (model != null) { setState(() { - performanceModels.add(model); + performanceInfos.add(model); }); } }, ), ), - for (final performance in performanceModels) - ListTile( - title: Text(performance.person != null - ? '${performance.person.firstName} ${performance.person.lastName}' - : performance.ensemble.name), - subtitle: - performance.role != null ? Text(performance.role.name) : null, - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - setState(() { - performanceModels.remove(performance); - }); - }, - ), - ), + ...performanceTiles, ], ), ); diff --git a/mobile/lib/editors/tracks.dart b/mobile/lib/editors/tracks.dart index b6c1a9b..e8f8abd 100644 --- a/mobile/lib/editors/tracks.dart +++ b/mobile/lib/editors/tracks.dart @@ -22,7 +22,8 @@ class TracksEditor extends StatefulWidget { class _TracksEditorState extends State { BackendState backend; - int recordingId; + WorkInfo workInfo; + RecordingInfo recordingInfo; String parentId; List trackModels = []; @@ -44,12 +45,72 @@ class _TracksEditorState extends State { tracks.add(Track( fileName: trackModel.fileName, - recordingId: recordingId, + 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. + + // TODO: Think about efficiency. + backend.db.transaction(() async { + for (final composer in workInfo.composers) { + await backend.db.updatePerson(composer); + } + + for (final instrument in workInfo.instruments) { + await backend.db.updateInstrument(instrument); + } + + for (final partInfo in workInfo.parts) { + for (final instrument in partInfo.instruments) { + await backend.db.updateInstrument(instrument); + } + } + + await backend.db.updateWork(WorkData( + data: WorkPartData( + work: workInfo.work, + instrumentIds: + workInfo.instruments.map((i) => i.id).toList(), + ), + partData: workInfo.parts + .map((p) => WorkPartData( + work: p.work, + instrumentIds: + p.instruments.map((i) => i.id).toList(), + )) + .toList(), + )); + + for (final performance in recordingInfo.performances) { + if (performance.person != null) { + await backend.db.updatePerson(performance.person); + } + if (performance.ensemble != null) { + await backend.db.updateEnsemble(performance.ensemble); + } + if (performance.role != null) { + await backend.db.updateInstrument(performance.role); + } + } + + await backend.db.updateRecording(RecordingData( + recording: recordingInfo.recording, + performances: recordingInfo.performances + .map((p) => Performance( + recording: recordingInfo.recording.id, + person: p.person?.id, + ensemble: p.ensemble?.id, + role: p.role?.id, + )) + .toList(), + )); + }); + backend.ml.addTracks(parentId, tracks); Navigator.pop(context); @@ -61,9 +122,10 @@ class _TracksEditorState extends State { header: Column( children: [ ListTile( - title: recordingId != null + title: recordingInfo != null ? RecordingTile( - recordingId: recordingId, + workInfo: workInfo, + recordingInfo: recordingInfo, ) : Text('Select recording'), onTap: selectRecording, @@ -92,7 +154,7 @@ class _TracksEditorState extends State { trackModels = newTrackModels; }); - if (recordingId != null) { + if (recordingInfo != null) { updateAutoParts(); } } @@ -121,16 +183,17 @@ class _TracksEditorState extends State { } Future selectRecording() async { - final Recording recording = await Navigator.push( + final RecordingSelectorResult result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => RecordingsSelector(), + builder: (context) => RecordingSelector(), ), ); - if (recording != null) { + if (result != null) { setState(() { - recordingId = recording.id; + workInfo = result.workInfo; + recordingInfo = result.recordingInfo; }); updateAutoParts(); @@ -139,18 +202,14 @@ class _TracksEditorState extends State { /// Automatically associate the tracks with work parts. Future updateAutoParts() async { - final recording = await backend.db.recordingById(recordingId).getSingle(); - final workId = recording.work; - final workParts = await backend.db.workParts(workId).get(); - setState(() { for (var i = 0; i < trackModels.length; i++) { - if (i >= workParts.length) { + if (i >= workInfo.parts.length) { trackModels[i].workPartIndex = null; trackModels[i].workPartTitle = null; } else { - trackModels[i].workPartIndex = workParts[i].partIndex; - trackModels[i].workPartTitle = workParts[i].title; + trackModels[i].workPartIndex = workInfo.parts[i].work.partIndex; + trackModels[i].workPartTitle = workInfo.parts[i].work.title; } } }); diff --git a/mobile/lib/editors/work.dart b/mobile/lib/editors/work.dart index f1b0adf..d3faf83 100644 --- a/mobile/lib/editors/work.dart +++ b/mobile/lib/editors/work.dart @@ -152,6 +152,10 @@ class _PartTileState extends State { } } +/// Screen for editing a work. +/// +/// If the user is finished editing, the result will be returned as a [WorkInfo] +/// object. class WorkEditor extends StatefulWidget { final Work work; @@ -319,12 +323,12 @@ class _WorkEditorState extends State { )); } - await backend.db.updateWork(WorkData( + final workInfo = await backend.client.putWork(WorkData( data: data, partData: partData, )); - Navigator.pop(context, data.work); + Navigator.pop(context, workInfo); }, ), ], diff --git a/mobile/lib/screens/home.dart b/mobile/lib/screens/home.dart index e556700..28e65c5 100644 --- a/mobile/lib/screens/home.dart +++ b/mobile/lib/screens/home.dart @@ -55,7 +55,6 @@ class HomeScreen extends StatelessWidget { ), ], ), - // For debugging purposes body: StreamBuilder>( stream: backend.db.allPersons().watch(), builder: (context, snapshot) { diff --git a/mobile/lib/screens/person.dart b/mobile/lib/screens/person.dart index 5d1e49a..c8d68b5 100644 --- a/mobile/lib/screens/person.dart +++ b/mobile/lib/screens/person.dart @@ -16,7 +16,7 @@ class PersonScreen extends StatelessWidget { @override Widget build(BuildContext context) { final backend = Backend.of(context); - + return Scaffold( appBar: AppBar( title: Text('${person.firstName} ${person.lastName}'), diff --git a/mobile/lib/screens/program.dart b/mobile/lib/screens/program.dart index 0d19588..ff3936f 100644 --- a/mobile/lib/screens/program.dart +++ b/mobile/lib/screens/program.dart @@ -8,90 +8,6 @@ import '../music_library.dart'; import '../widgets/play_pause_button.dart'; import '../widgets/recording_tile.dart'; -/// Data class to bundle information from the database on one track. -class ProgramItem { - /// ID of the recording. - /// - /// We don't need the real recording, as the [RecordingTile] widget handles - /// that for us. If the recording is the same one, as the one from the - /// previous track, this will be null. - final int recordingId; - - /// List of work parts contained in this track. - /// - /// This will include the parts linked in the track as well as all parents of - /// them, if there are gaps between them (i.e. some parts are missing). - final List workParts; - - ProgramItem({ - this.recordingId, - this.workParts, - }); -} - -/// Widget displaying a [ProgramItem]. -class ProgramTile extends StatelessWidget { - final ProgramItem item; - final bool isPlaying; - - ProgramTile({ - this.item, - this.isPlaying, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Padding( - padding: const EdgeInsets.all(4.0), - child: isPlaying - ? const Icon(Icons.play_arrow) - : SizedBox( - width: 24.0, - height: 24.0, - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (item.recordingId != null) ...[ - RecordingTile( - recordingId: item.recordingId, - ), - SizedBox( - height: 8.0, - ), - ], - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final part in item.workParts) - Padding( - padding: const EdgeInsets.only( - left: 8.0, - ), - child: Text( - part.title, - style: TextStyle( - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ], - ); - } -} - class ProgramScreen extends StatefulWidget { @override _ProgramScreenState createState() => _ProgramScreenState(); @@ -103,7 +19,7 @@ class _ProgramScreenState extends State { StreamSubscription playerActiveSubscription; StreamSubscription> playlistSubscription; - List items = []; + List widgets = []; StreamSubscription positionSubscription; double position = 0.0; @@ -149,7 +65,7 @@ class _ProgramScreenState extends State { /// Go through the tracks of [playlist] and preprocess them for displaying. Future updateProgram(List playlist) async { - List newItems = []; + List newWidgets = []; // The following variables exist to adapt the resulting ProgramItem to its // predecessor. @@ -162,42 +78,59 @@ class _ProgramScreenState extends State { // from the database again. int lastWorkId; - // This will always contain the parts of the current work. - List workParts = []; + // This will contain information on the last new work. + WorkInfo workInfo; for (var i = 0; i < playlist.length; i++) { - // The data that will be stored in the resulting ProgramItem. - int newRecordingId; - List newWorkParts = []; + // The widgets displayed for this track. + List children = []; final track = playlist[i]; final recordingId = track.track.recordingId; final partIds = track.track.partIds; - // newRecordingId will be null, if the recording ID is the same. This - // also means, that the work is the same, so workParts doesn't have to - // be updated either. + // If the recording is the same, the work will also be the same, so + // workInfo doesn't have to be updated either. if (recordingId != lastRecordingId) { lastRecordingId = recordingId; - newRecordingId = recordingId; - final recording = - await backend.db.recordingById(recordingId).getSingle(); + final recordingInfo = await backend.db.getRecording(recordingId); - if (recording.work != lastWorkId) { - workParts = await backend.db.workParts(recording.work).get(); + if (recordingInfo.recording.work != lastWorkId) { + lastWorkId = recordingInfo.recording.work; + workInfo = await backend.db.getWork(lastWorkId); } - lastWorkId = recording.work; + children.addAll([ + RecordingTile( + workInfo: workInfo, + recordingInfo: recordingInfo, + ), + SizedBox( + height: 8.0, + ), + ]); } for (final partId in partIds) { - newWorkParts.add(workParts[partId]); + final partInfo = workInfo.parts[partId]; + + children.add(Padding( + padding: const EdgeInsets.only( + left: 8.0, + ), + child: Text( + partInfo.work.title, + style: TextStyle( + fontStyle: FontStyle.italic, + ), + ), + )); } - newItems.add(ProgramItem( - recordingId: newRecordingId, - workParts: newWorkParts, + newWidgets.add(Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, )); } @@ -205,7 +138,7 @@ class _ProgramScreenState extends State { // function might take some time. if (mounted) { setState(() { - items = newItems; + widgets = newWidgets; }); } } @@ -225,12 +158,27 @@ class _ProgramScreenState extends State { builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( - itemCount: items.length, + itemCount: widgets.length, itemBuilder: (context, index) { return InkWell( - child: ProgramTile( - item: items[index], - isPlaying: index == snapshot?.data, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(4.0), + child: index == snapshot.data + ? const Icon(Icons.play_arrow) + : SizedBox( + width: 24.0, + height: 24.0, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: widgets[index], + ), + ), + ], ), onTap: () { backend.player.skipTo(index); diff --git a/mobile/lib/screens/work.dart b/mobile/lib/screens/work.dart index 33d28c8..e8aed33 100644 --- a/mobile/lib/screens/work.dart +++ b/mobile/lib/screens/work.dart @@ -44,8 +44,20 @@ class WorkScreen extends StatelessWidget { itemCount: snapshot.data.length, itemBuilder: (context, index) { final recording = snapshot.data[index]; + return ListTile( - title: PerformancesText(recording.id), + title: FutureBuilder( + future: backend.db.getRecordingInfo(recording), + builder: (context, snapshot) { + if (snapshot.hasData) { + return PerformancesText( + performanceInfos: snapshot.data.performances, + ); + } else { + return Text('...'); + } + } + ), onTap: () async { final tracks = backend.ml.tracks[recording.id]; tracks.sort( diff --git a/mobile/lib/selectors/ensemble.dart b/mobile/lib/selectors/ensemble.dart new file mode 100644 index 0000000..068ef29 --- /dev/null +++ b/mobile/lib/selectors/ensemble.dart @@ -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); + } + }, + ), + ); + } +} diff --git a/mobile/lib/selectors/instruments.dart b/mobile/lib/selectors/instruments.dart index 7f7ca90..bcfbdba 100644 --- a/mobile/lib/selectors/instruments.dart +++ b/mobile/lib/selectors/instruments.dart @@ -35,7 +35,9 @@ class _InstrumentsSelectorState extends State { return Scaffold( appBar: AppBar( - title: Text(widget.multiple ? 'Select instruments/roles' : 'Select instrument/role'), + title: Text(widget.multiple + ? 'Select instruments/roles' + : 'Select instrument/role'), actions: widget.multiple ? [ FlatButton( @@ -45,8 +47,8 @@ class _InstrumentsSelectorState extends State { ] : null, ), - body: StreamBuilder( - stream: backend.db.allInstruments().watch(), + body: FutureBuilder>( + future: backend.client.getInstruments(), builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( diff --git a/mobile/lib/selectors/performer.dart b/mobile/lib/selectors/performer.dart deleted file mode 100644 index dcc66a0..0000000 --- a/mobile/lib/selectors/performer.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:musicus_database/musicus_database.dart'; - -import '../backend.dart'; -import '../editors/ensemble.dart'; -import '../editors/person.dart'; - -import 'instruments.dart'; - -class PerformanceModel { - final Person person; - final Ensemble ensemble; - final Instrument role; - - PerformanceModel({ - this.person, - this.ensemble, - this.role, - }); -} - -class PerformerSelector extends StatefulWidget { - @override - _PerformerSelectorState createState() => _PerformerSelectorState(); -} - -class _Selection { - final bool isPerson; - final Person person; - final Ensemble ensemble; - - _Selection.person(this.person) - : isPerson = true, - ensemble = null; - - _Selection.ensemble(this.ensemble) - : isPerson = false, - person = null; -} - -class _PerformerSelectorState extends State { - Instrument role; - _Selection selection; - - @override - Widget build(BuildContext context) { - final backend = Backend.of(context); - - return Scaffold( - appBar: AppBar( - title: Text('Select performer'), - actions: [ - FlatButton( - child: Text('DONE'), - onPressed: () => Navigator.pop( - context, - PerformanceModel( - person: selection?.person, - ensemble: selection?.ensemble, - role: role, - ), - ), - ), - ], - ), - body: Column( - children: [ - Material( - elevation: 2.0, - child: ListTile( - title: Text('Instrument/Role'), - subtitle: - Text(role != null ? role.name : 'Select instrument/role'), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - setState(() { - role = null; - }); - }, - ), - onTap: () async { - final Instrument newRole = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => InstrumentsSelector(), - fullscreenDialog: true, - )); - - if (newRole != null) { - setState(() { - role = newRole; - }); - } - }, - ), - ), - Expanded( - child: ListView( - children: [ - StreamBuilder>( - stream: backend.db.allPersons().watch(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data.isNotEmpty) { - return ExpansionTile( - initiallyExpanded: true, - title: Text('Persons'), - children: snapshot.data - .map((person) => RadioListTile( - title: Text( - '${person.lastName}, ${person.firstName}'), - value: person, - groupValue: selection?.person, - onChanged: (person) { - setState(() { - selection = _Selection.person(person); - }); - }, - )) - .toList(), - ); - } else { - return Container(); - } - }, - ), - StreamBuilder>( - stream: backend.db.allEnsembles().watch(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data.isNotEmpty) { - return ExpansionTile( - initiallyExpanded: true, - title: Text('Ensembles'), - children: snapshot.data - .map((ensemble) => RadioListTile( - title: Text(ensemble.name), - value: ensemble, - groupValue: selection?.ensemble, - onChanged: (ensemble) { - setState(() { - selection = _Selection.ensemble(ensemble); - }); - }, - )) - .toList(), - ); - } else { - return Container(); - } - }, - ), - ], - ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () async { - showModalBottomSheet( - context: context, - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.add), - title: Text('Add person'), - onTap: () async { - final Person person = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PersonEditor(), - fullscreenDialog: true, - )); - - if (person != null) { - setState(() { - selection = _Selection.person(person); - }); - } - - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.add), - title: Text('Add ensemble'), - onTap: () async { - final Ensemble ensemble = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EnsembleEditor(), - fullscreenDialog: true, - )); - - if (ensemble != null) { - setState(() { - selection = _Selection.ensemble(ensemble); - }); - } - - Navigator.pop(context); - }, - ), - ], - ), - ); - }, - ), - ); - } -} diff --git a/mobile/lib/selectors/person.dart b/mobile/lib/selectors/person.dart index 40fc7ef..8fd1a17 100644 --- a/mobile/lib/selectors/person.dart +++ b/mobile/lib/selectors/person.dart @@ -1,47 +1,35 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.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) { - final backend = Backend.of(context); - return Scaffold( appBar: AppBar( title: Text('Select person'), ), - body: StreamBuilder( - stream: backend.db.allPersons().watch(), - 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: () => Navigator.pop(context, person), - ); - }, - ); - } else { - return Container(); - } + 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, - )); + context, + MaterialPageRoute( + builder: (context) => PersonEditor(), + fullscreenDialog: true, + ), + ); if (person != null) { Navigator.pop(context, person); diff --git a/mobile/lib/selectors/recording.dart b/mobile/lib/selectors/recording.dart index d4aba4f..16bdd18 100644 --- a/mobile/lib/selectors/recording.dart +++ b/mobile/lib/selectors/recording.dart @@ -1,211 +1,90 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; import '../editors/recording.dart'; -import '../widgets/texts.dart'; -import '../widgets/works_by_composer.dart'; +import '../widgets/lists.dart'; -class PersonList extends StatelessWidget { - final void Function(int personId) onSelect; +class RecordingSelectorResult { + final WorkInfo workInfo; + final RecordingInfo recordingInfo; - PersonList({ - this.onSelect, + RecordingSelectorResult({ + this.workInfo, + this.recordingInfo, }); - - @override - Widget build(BuildContext context) { - final backend = Backend.of(context); - - return Column( - children: [ - Material( - elevation: 2.0, - child: ListTile( - title: Text('Composers'), - ), - ), - Expanded( - child: StreamBuilder>( - stream: backend.db.allPersons().watch(), - 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: () => onSelect(person.id), - ); - }, - ); - } else { - return Container(); - } - }, - ), - ), - ], - ); - } } -class WorkList extends StatelessWidget { - final int composerId; - final void Function(int workId) onSelect; +/// 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(); +} - WorkList({ - this.composerId, - this.onSelect, - }); +class _RecordingSelectorState extends State { + Person person; + WorkInfo workInfo; @override Widget build(BuildContext context) { - return Column( - children: [ - Material( - elevation: 2.0, - child: ListTile( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(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, ), - title: PersonText(composerId), - ), - ), - Expanded( - child: WorksByComposer( - personId: composerId, - onTap: (selectedWork) => onSelect(selectedWork.id), - ), - ), - ], - ); - } -} - -class RecordingList extends StatelessWidget { - final int workId; - final void Function(Recording recording) onSelect; - - RecordingList({ - this.workId, - this.onSelect, - }); - - @override - Widget build(BuildContext context) { - final backend = Backend.of(context); - return Column( - children: [ - Material( - elevation: 2.0, - child: ListTile( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - title: WorkText(workId), - subtitle: ComposersText(workId), - ), - ), - Expanded( - child: StreamBuilder>( - stream: backend.db.recordingsByWork(workId).watch(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final recording = snapshot.data[index]; - return ListTile( - title: PerformancesText(recording.id), - onTap: () => onSelect(recording), - ); - }, - ); - } else { - return Container(); - } - }, - ), - ), - ], - ); - } -} - -class RecordingsSelector extends StatefulWidget { - @override - _RecordingsSelectorState createState() => _RecordingsSelectorState(); -} - -class _RecordingsSelectorState extends State { - final nestedNavigator = GlobalKey(); - - @override - Widget build(BuildContext context) { - // This exists to circumvent the nested navigator when selecting a - // recording. - void popUpperNavigator(Recording recording) { - Navigator.pop(context, recording); + ); + }, + ); } - return WillPopScope( - child: Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - title: Text('Select recording'), - ), - body: Navigator( - key: nestedNavigator, - onGenerateRoute: (settings) => settings.name == '/' - ? MaterialPageRoute( - builder: (context) => PersonList( - onSelect: (personId) => nestedNavigator.currentState.push( - MaterialPageRoute( - builder: (context) => WorkList( - composerId: personId, - onSelect: (workId) => - nestedNavigator.currentState.push( - MaterialPageRoute( - builder: (context) => RecordingList( - workId: workId, - onSelect: (recording) => - popUpperNavigator(recording), - ), - ), - ), - ), - ), - ), - ), - ) - : null, - initialRoute: '/', - ), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.add), - onPressed: () async { - final recording = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => RecordingEditor(), - fullscreenDialog: true, - ), - ); - - if (recording != null) { - Navigator.pop(context, recording); - } - }, - ), + 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); + } + }, ), - onWillPop: () async => !(await nestedNavigator.currentState.maybePop()), ); } } diff --git a/mobile/lib/selectors/work.dart b/mobile/lib/selectors/work.dart index 5a306b9..364f4f6 100644 --- a/mobile/lib/selectors/work.dart +++ b/mobile/lib/selectors/work.dart @@ -1,68 +1,62 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.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 { + Person person; -// TODO: Lazy load works and/or optimize queries. -class WorkSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(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: StreamBuilder>( - stream: backend.db.allPersons().watch(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data.length, - itemBuilder: (context, index) { - final person = snapshot.data[index]; - final title = Text('${person.lastName}, ${person.firstName}'); - return StreamBuilder>( - stream: backend.db.worksByComposer(person.id).watch(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data.isNotEmpty) { - return ExpansionTile( - title: title, - children: [ - for (final work in snapshot.data) - ListTile( - title: Text(work.title), - onTap: () => Navigator.pop(context, work), - ), - ], - ); - } else { - return ListTile( - title: title, - ); - } - }, - ); - }, - ); - } else { - return Container(); - } - }, - ), + body: body, floatingActionButton: FloatingActionButton( child: const Icon(Icons.add), onPressed: () async { - final Work work = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => WorkEditor(), - fullscreenDialog: true, - )); + final WorkInfo workInfo = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WorkEditor(), + fullscreenDialog: true, + ), + ); - if (work != null) { - Navigator.pop(context, work); + if (workInfo != null) { + Navigator.pop(context, workInfo); } }, ), diff --git a/mobile/lib/widgets/lists.dart b/mobile/lib/widgets/lists.dart new file mode 100644 index 0000000..6e793f0 --- /dev/null +++ b/mobile/lib/widgets/lists.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:musicus_database/musicus_database.dart'; + +import '../backend.dart'; +import '../widgets/texts.dart'; + +/// A list of persons. +class PersonsList extends StatelessWidget { + /// Called, when the user has selected a person. + final void Function(Person person) onSelected; + + PersonsList({ + this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final backend = Backend.of(context); + + return FutureBuilder>( + 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); + } + }, + ); + }, + ); + } else { + return Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } +} + +/// A list of ensembles. +class EnsemblesList extends StatelessWidget { + /// Called, when the user has selected an ensemble. + final void Function(Ensemble ensemble) onSelected; + + EnsemblesList({ + this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final backend = Backend.of(context); + + return FutureBuilder>( + 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); + } + }, + ); + }, + ); + } else { + return Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } +} + +/// A list of works by one composer. +class WorksList extends StatelessWidget { + /// 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 + Widget build(BuildContext context) { + final backend = Backend.of(context); + + return FutureBuilder>( + 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); + } + }, + ); + }, + ); + } else { + return Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } +} + +/// 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 = Backend.of(context); + + return FutureBuilder>( + 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(), + ); + } + }, + ); + } +} diff --git a/mobile/lib/widgets/recording_tile.dart b/mobile/lib/widgets/recording_tile.dart index 61a5bb4..257f38f 100644 --- a/mobile/lib/widgets/recording_tile.dart +++ b/mobile/lib/widgets/recording_tile.dart @@ -1,50 +1,48 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; - import 'texts.dart'; class RecordingTile extends StatelessWidget { - final int recordingId; + final WorkInfo workInfo; + final RecordingInfo recordingInfo; RecordingTile({ - this.recordingId, + this.workInfo, + this.recordingInfo, }); @override Widget build(BuildContext context) { - final backend = Backend.of(context); final textTheme = Theme.of(context).textTheme; - return StreamBuilder( - stream: backend.db.recordingById(recordingId).watchSingle(), - builder: (context, snapshot) => Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (snapshot.hasData) ...[ - DefaultTextStyle( - style: textTheme.subtitle1, - child: ComposersText(snapshot.data.work), - ), - DefaultTextStyle( - style: textTheme.headline6, - child: WorkText(snapshot.data.work), - ), - ], - const SizedBox( - height: 4.0, + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, ), - DefaultTextStyle( - style: textTheme.bodyText1, - child: PerformancesText(recordingId), - ), - ], - ), + ), + ], ), ); } diff --git a/mobile/lib/widgets/texts.dart b/mobile/lib/widgets/texts.dart index 1cb42ea..95d4d2a 100644 --- a/mobile/lib/widgets/texts.dart +++ b/mobile/lib/widgets/texts.dart @@ -49,72 +49,38 @@ class PersonText extends StatelessWidget { } } -class PerformancesText extends StatefulWidget { - final int recordingId; +/// A widget showing information on a list of performances. +class PerformancesText extends StatelessWidget { + /// The information to show. + final List performanceInfos; - PerformancesText(this.recordingId); - - @override - _PerformancesTextState createState() => _PerformancesTextState(); -} - -class _PerformancesTextState extends State { - BackendState backend; - StreamSubscription> performancesSubscription; - String text = '...'; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - performancesSubscription?.cancel(); - backend = Backend.of(context); - - performancesSubscription = backend.db - .performancesByRecording(widget.recordingId) - .watch() - .listen((performances) async { - final List texts = []; - - for (final performance in performances) { - final buffer = StringBuffer(); - - if (performance.person != null) { - final person = - await backend.db.personById(performance.person).getSingle(); - buffer.write('${person.firstName} ${person.lastName}'); - } else if (performance.ensemble != null) { - final ensemble = - await backend.db.ensembleById(performance.ensemble).getSingle(); - buffer.write(ensemble.name); - } else { - buffer.write('Unknown'); - } - - if (performance.role != null) { - final role = - await backend.db.instrumentById(performance.role).getSingle(); - buffer.write(' (${role.name})'); - } - - texts.add(buffer.toString()); - } - - setState(() { - text = texts.join(', '); - }); - }); - } + PerformancesText({ + this.performanceInfos, + }); @override Widget build(BuildContext context) { - return Text(text); - } + final List performanceTexts = []; - @override - void dispose() { - super.dispose(); - performancesSubscription?.cancel(); + 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(', ')); } }