diff --git a/packages/neon/lib/src/apps/notes/app.dart b/packages/neon/lib/src/apps/notes/app.dart index 2a82b692..002e7943 100644 --- a/packages/neon/lib/src/apps/notes/app.dart +++ b/packages/neon/lib/src/apps/notes/app.dart @@ -1,13 +1,17 @@ library notes; +import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_rx_bloc/flutter_rx_bloc.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/apps/notes/blocs/note.dart'; import 'package:neon/src/apps/notes/blocs/notes.dart'; +import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/neon.dart'; import 'package:nextcloud/nextcloud.dart'; diff --git a/packages/neon/lib/src/apps/notes/blocs/note.dart b/packages/neon/lib/src/apps/notes/blocs/note.dart new file mode 100644 index 00000000..44426cd3 --- /dev/null +++ b/packages/neon/lib/src/apps/notes/blocs/note.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:neon/src/apps/notes/app.dart'; +import 'package:neon/src/apps/notes/blocs/notes.dart'; +import 'package:neon/src/neon.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:rx_bloc/rx_bloc.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'note.rxb.g.dart'; + +abstract class NotesNoteBlocEvents { + void updateNote( + final int id, + final String etag, { + final String? title, + final String? category, + final String? content, + final bool? favorite, + }); +} + +abstract class NotesNoteBlocStates { + BehaviorSubject get content; + + BehaviorSubject get title; + + BehaviorSubject get category; + + BehaviorSubject get etag; + + Stream get errors; +} + +@RxBloc() +class NotesNoteBloc extends $NotesNoteBloc { + NotesNoteBloc( + this.options, + this._requestManager, + this._client, + this._notesBloc, + final NotesNote note, + ) { + _$updateNoteEvent.listen((final event) { + _wrapAction( + () async { + _emitNote( + await _client.notes.updateNote( + id: event.id, + title: event.title, + category: event.category, + content: event.content, + favorite: event.favorite ?? false ? 1 : 0, + ifMatch: '"${event.etag}"', + ), + ); + }, + ); + }); + + _emitNote(note); + id = note.id; + } + + void _emitNote(final NotesNote note) { + _contentSubject.add(note.content); + _titleSubject.add(note.title); + _categorySubject.add(note.category); + _etagSubject.add(note.etag); + } + + void _wrapAction(final Future Function() call) { + final stream = _requestManager.wrapWithoutCache(call).asBroadcastStream(); + stream.whereError().listen(_errorsStreamController.add); + stream.whereSuccess().listen((final _) async { + _notesBloc.refresh(); + }); + } + + final NotesAppSpecificOptions options; + final RequestManager _requestManager; + final NextcloudClient _client; + final NotesBloc _notesBloc; + + late final int id; + final _contentSubject = BehaviorSubject(); + final _titleSubject = BehaviorSubject(); + final _categorySubject = BehaviorSubject(); + final _etagSubject = BehaviorSubject(); + final _errorsStreamController = StreamController(); + + @override + void dispose() { + unawaited(_contentSubject.close()); + unawaited(_titleSubject.close()); + unawaited(_categorySubject.close()); + unawaited(_etagSubject.close()); + unawaited(_errorsStreamController.close()); + super.dispose(); + } + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); + + @override + BehaviorSubject _mapToContentState() => _contentSubject; + + @override + BehaviorSubject _mapToTitleState() => _titleSubject; + + @override + BehaviorSubject _mapToCategoryState() => _categorySubject; + + @override + BehaviorSubject _mapToEtagState() => _etagSubject; +} diff --git a/packages/neon/lib/src/apps/notes/blocs/note.rxb.g.dart b/packages/neon/lib/src/apps/notes/blocs/note.rxb.g.dart new file mode 100644 index 00000000..6daf4dba --- /dev/null +++ b/packages/neon/lib/src/apps/notes/blocs/note.rxb.g.dart @@ -0,0 +1,120 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// Generator: RxBlocGeneratorForAnnotation +// ************************************************************************** + +part of 'note.dart'; + +/// Used as a contractor for the bloc, events and states classes +/// {@nodoc} +abstract class NotesNoteBlocType extends RxBlocTypeBase { + NotesNoteBlocEvents get events; + NotesNoteBlocStates get states; +} + +/// [$NotesNoteBloc] extended by the [NotesNoteBloc] +/// {@nodoc} +abstract class $NotesNoteBloc extends RxBlocBase + implements NotesNoteBlocEvents, NotesNoteBlocStates, NotesNoteBlocType { + final _compositeSubscription = CompositeSubscription(); + + /// Тhe [Subject] where events sink to by calling [updateNote] + final _$updateNoteEvent = PublishSubject<_UpdateNoteEventArgs>(); + + /// The state of [content] implemented in [_mapToContentState] + late final BehaviorSubject _contentState = _mapToContentState(); + + /// The state of [title] implemented in [_mapToTitleState] + late final BehaviorSubject _titleState = _mapToTitleState(); + + /// The state of [category] implemented in [_mapToCategoryState] + late final BehaviorSubject _categoryState = _mapToCategoryState(); + + /// The state of [etag] implemented in [_mapToEtagState] + late final BehaviorSubject _etagState = _mapToEtagState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void updateNote( + int id, + String etag, { + String? title, + String? category, + String? content, + bool? favorite, + }) => + _$updateNoteEvent.add(_UpdateNoteEventArgs( + id, + etag, + title: title, + category: category, + content: content, + favorite: favorite, + )); + + @override + BehaviorSubject get content => _contentState; + + @override + BehaviorSubject get title => _titleState; + + @override + BehaviorSubject get category => _categoryState; + + @override + BehaviorSubject get etag => _etagState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject _mapToContentState(); + + BehaviorSubject _mapToTitleState(); + + BehaviorSubject _mapToCategoryState(); + + BehaviorSubject _mapToEtagState(); + + Stream _mapToErrorsState(); + + @override + NotesNoteBlocEvents get events => this; + + @override + NotesNoteBlocStates get states => this; + + @override + void dispose() { + _$updateNoteEvent.close(); + _compositeSubscription.dispose(); + super.dispose(); + } +} + +/// Helps providing the arguments in the [Subject.add] for +/// [NotesNoteBlocEvents.updateNote] event +class _UpdateNoteEventArgs { + const _UpdateNoteEventArgs( + this.id, + this.etag, { + this.title, + this.category, + this.content, + this.favorite, + }); + + final int id; + + final String etag; + + final String? title; + + final String? category; + + final String? content; + + final bool? favorite; +} diff --git a/packages/neon/lib/src/apps/notes/blocs/notes.dart b/packages/neon/lib/src/apps/notes/blocs/notes.dart index d12071bc..796e86da 100644 --- a/packages/neon/lib/src/apps/notes/blocs/notes.dart +++ b/packages/neon/lib/src/apps/notes/blocs/notes.dart @@ -32,8 +32,6 @@ abstract class NotesBlocEvents { abstract class NotesBlocStates { BehaviorSubject>> get notes; - Stream get noteUpdate; - Stream get errors; } @@ -57,15 +55,13 @@ class NotesBloc extends $NotesBloc { _$updateNoteEvent.listen((final event) { _wrapAction( - () async => _noteUpdateController.add( - await client.notes.updateNote( - id: event.id, - title: event.title, - category: event.category, - content: event.content, - favorite: event.favorite ?? false ? 1 : 0, - ifMatch: '"${event.etag}"', - ), + () async => client.notes.updateNote( + id: event.id, + title: event.title, + category: event.category, + content: event.content, + favorite: event.favorite ?? false ? 1 : 0, + ifMatch: '"${event.etag}"', ), ); }); @@ -102,7 +98,6 @@ class NotesBloc extends $NotesBloc { final NextcloudClient client; final _notesSubject = BehaviorSubject>>(); - final _noteUpdateController = StreamController(); final _errorsStreamController = StreamController(); @override @@ -115,9 +110,6 @@ class NotesBloc extends $NotesBloc { @override BehaviorSubject>> _mapToNotesState() => _notesSubject; - @override - Stream _mapToNoteUpdateState() => _noteUpdateController.stream.asBroadcastStream(); - @override Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); } diff --git a/packages/neon/lib/src/apps/notes/blocs/notes.rxb.g.dart b/packages/neon/lib/src/apps/notes/blocs/notes.rxb.g.dart index 278dae37..7c376d26 100644 --- a/packages/neon/lib/src/apps/notes/blocs/notes.rxb.g.dart +++ b/packages/neon/lib/src/apps/notes/blocs/notes.rxb.g.dart @@ -33,9 +33,6 @@ abstract class $NotesBloc extends RxBlocBase implements NotesBlocEvents, NotesBl /// The state of [notes] implemented in [_mapToNotesState] late final BehaviorSubject>> _notesState = _mapToNotesState(); - /// The state of [noteUpdate] implemented in [_mapToNoteUpdateState] - late final Stream _noteUpdateState = _mapToNoteUpdateState(); - /// The state of [errors] implemented in [_mapToErrorsState] late final Stream _errorsState = _mapToErrorsState(); @@ -76,16 +73,11 @@ abstract class $NotesBloc extends RxBlocBase implements NotesBlocEvents, NotesBl @override BehaviorSubject>> get notes => _notesState; - @override - Stream get noteUpdate => _noteUpdateState; - @override Stream get errors => _errorsState; BehaviorSubject>> _mapToNotesState(); - Stream _mapToNoteUpdateState(); - Stream _mapToErrorsState(); @override diff --git a/packages/neon/lib/src/apps/notes/dialogs/select_category.dart b/packages/neon/lib/src/apps/notes/dialogs/select_category.dart index cfcc6612..c745b346 100644 --- a/packages/neon/lib/src/apps/notes/dialogs/select_category.dart +++ b/packages/neon/lib/src/apps/notes/dialogs/select_category.dart @@ -3,12 +3,12 @@ part of '../app.dart'; class NotesSelectCategoryDialog extends StatefulWidget { const NotesSelectCategoryDialog({ required this.bloc, - required this.note, + this.initialCategory, super.key, }); final NotesBloc bloc; - final NotesNote note; + final String? initialCategory; @override State createState() => _NotesSelectCategoryDialogState(); @@ -21,7 +21,7 @@ class _NotesSelectCategoryDialogState extends State { void submit() { if (formKey.currentState!.validate()) { - Navigator.of(context).pop(selectedCategory ?? widget.note.category); + Navigator.of(context).pop(selectedCategory); } } @@ -60,7 +60,7 @@ class _NotesSelectCategoryDialogState extends State { if (notesData != null) ...[ NotesCategorySelect( categories: notesData.map((final note) => note.category).toSet().toList(), - initialValue: widget.note.category, + initialValue: widget.initialCategory, onChanged: (final category) { selectedCategory = category; }, diff --git a/packages/neon/lib/src/apps/notes/pages/note.dart b/packages/neon/lib/src/apps/notes/pages/note.dart index 1e22f298..58d50490 100644 --- a/packages/neon/lib/src/apps/notes/pages/note.dart +++ b/packages/neon/lib/src/apps/notes/pages/note.dart @@ -3,41 +3,41 @@ part of '../app.dart'; class NotesNotePage extends StatefulWidget { const NotesNotePage({ required this.bloc, - required this.note, + required this.notesBloc, super.key, }); - final NotesBloc bloc; - final NotesNote note; + final NotesNoteBloc bloc; + final NotesBloc notesBloc; @override State createState() => _NotesNotePageState(); } class _NotesNotePageState extends State { - late final _contentController = TextEditingController(text: widget.note.content); - late final _titleController = TextEditingController(text: widget.note.title); + late final _contentController = TextEditingController(); + late final _titleController = TextEditingController(); final _contentFocusNode = FocusNode(); final _titleFocusNode = FocusNode(); - - late NotesNote _note = widget.note; + final _updateController = StreamController(); bool _showEditor = false; - bool _synced = true; void _focusEditor() { _contentFocusNode.requestFocus(); _contentController.selection = TextSelection.collapsed(offset: _contentController.text.length); } - void _update([final String? selectedCategory]) { - final updatedTitle = _note.title != _titleController.text ? _titleController.text : null; - final updatedCategory = selectedCategory != null && _note.category != selectedCategory ? selectedCategory : null; - final updatedContent = _note.content != _contentController.text ? _contentController.text : null; + Future _update({ + final String? category, + }) async { + final updatedTitle = await widget.bloc.title.first != _titleController.text ? _titleController.text : null; + final updatedCategory = category != null && await widget.bloc.category.first != category ? category : null; + final updatedContent = await widget.bloc.content.first != _contentController.text ? _contentController.text : null; if (updatedTitle != null || updatedCategory != null || updatedContent != null) { widget.bloc.updateNote( - _note.id, - _note.etag, + widget.bloc.id, + await widget.bloc.etag.first, title: updatedTitle, category: updatedCategory, content: updatedContent, @@ -49,33 +49,28 @@ class _NotesNotePageState extends State { void initState() { super.initState(); - void updateSynced() { - _synced = _note.content == _contentController.text; - } - - _contentController.addListener(() => setState(updateSynced)); + widget.bloc.errors.listen((final error) { + handleNotesException(context, error); + }); - widget.bloc.noteUpdate.listen((final n) { - if (mounted && n.id == _note.id) { - setState(() { - _note = n; - updateSynced(); - }); - } + widget.bloc.content.listen((final content) { + _contentController.text = content; }); - _titleFocusNode.addListener(() { - if (!_titleFocusNode.hasFocus) { - _update(); - } + widget.bloc.title.listen((final title) { + _titleController.text = title; }); + _contentController.addListener(() => _updateController.add(null)); + _titleController.addListener(() => _updateController.add(null)); + _updateController.stream.debounceTime(const Duration(seconds: 1)).listen((final _) async => _update()); + WidgetsBinding.instance.addPostFrameCallback((final _) async { if (Provider.of(context, listen: false).canUseWakelock) { await Wakelock.enable(); } if (widget.bloc.options.defaultNoteViewTypeOption.value == DefaultNoteViewType.edit || - widget.note.content.isEmpty) { + (await widget.bloc.content.first).isEmpty) { setState(() { _showEditor = true; }); @@ -84,11 +79,20 @@ class _NotesNotePageState extends State { }); } + @override + void dispose() { + unawaited(_updateController.close()); + super.dispose(); + } + @override Widget build(final BuildContext context) => WillPopScope( onWillPop: () async { - _update(); + await _update(); + if (!mounted) { + return true; + } if (Provider.of(context, listen: false).canUseWakelock) { await Wakelock.disable(); } @@ -112,12 +116,6 @@ class _NotesNotePageState extends State { ), ), actions: [ - IconButton( - icon: Icon( - _synced ? Icons.check : Icons.sync, - ), - onPressed: _update, - ), IconButton( icon: Icon( _showEditor ? Icons.visibility : Icons.edit, @@ -135,23 +133,33 @@ class _NotesNotePageState extends State { } }, ), - IconButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (final context) => NotesSelectCategoryDialog( - bloc: widget.bloc, - note: _note, + RxBlocBuilder( + bloc: widget.bloc, + state: (final bloc) => bloc.category, + builder: (final context, final categorySnapshot, final _) { + final category = categorySnapshot.data ?? ''; + + return IconButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (final context) => NotesSelectCategoryDialog( + bloc: widget.notesBloc, + initialCategory: category, + ), + ); + if (result != null) { + await _update( + category: result, + ); + } + }, + icon: Icon( + MdiIcons.tag, + color: category.isNotEmpty ? NotesCategoryColor.compute(category) : null, ), ); - if (result != null) { - _update(result); - } }, - icon: Icon( - MdiIcons.tag, - color: _note.category.isNotEmpty ? NotesCategoryColor.compute(_note.category) : null, - ), ), ], ), @@ -178,15 +186,22 @@ class _NotesNotePageState extends State { border: InputBorder.none, ), ) - : MarkdownBody( - data: _contentController.text, - onTapLink: (final text, final href, final title) async { - if (href != null) { - await launchUrlString( - href, - mode: LaunchMode.externalApplication, - ); - } + : RxBlocBuilder( + bloc: widget.bloc, + state: (final bloc) => bloc.content, + builder: (final context, final contentSnapshot, final _) { + final content = contentSnapshot.data ?? ''; + return MarkdownBody( + data: content, + onTapLink: (final text, final href, final title) async { + if (href != null) { + await launchUrlString( + href, + mode: LaunchMode.externalApplication, + ); + } + }, + ); }, ), ), diff --git a/packages/neon/lib/src/apps/notes/widgets/notes_view.dart b/packages/neon/lib/src/apps/notes/widgets/notes_view.dart index 443d0fe4..1c71cc28 100644 --- a/packages/neon/lib/src/apps/notes/widgets/notes_view.dart +++ b/packages/neon/lib/src/apps/notes/widgets/notes_view.dart @@ -125,8 +125,14 @@ class NotesView extends StatelessWidget { await Navigator.of(context).push( MaterialPageRoute( builder: (final context) => NotesNotePage( - bloc: bloc, - note: note, + bloc: NotesNoteBloc( + bloc.options, + Provider.of(context, listen: false), + RxBlocProvider.of(context).activeAccount.value!.client, + bloc, + note, + ), + notesBloc: bloc, ), ), );