From f57d943b097e2b0b9a75c5eb3252ef3deabf6845 Mon Sep 17 00:00:00 2001 From: Guilherme Girotto Date: Thu, 18 Nov 2021 17:34:34 -0300 Subject: [PATCH] Adds Memos List - closes #221 --- lib/application/constants/animations.dart | 2 + lib/application/constants/strings.dart | 4 +- .../update/update_collection_memos.dart | 173 +++++++++++++++--- .../update/update_collection_page.dart | 34 +++- .../update/update_collection_providers.dart | 4 + .../home/update_collection_memos_vm.dart | 72 ++++++++ .../home/update_collection_vm.dart | 21 ++- .../widgets/theme/memo_terminal.dart | 4 +- .../widgets/theme/rich_text_field.dart | 2 + 9 files changed, 276 insertions(+), 40 deletions(-) create mode 100644 lib/application/view-models/home/update_collection_memos_vm.dart diff --git a/lib/application/constants/animations.dart b/lib/application/constants/animations.dart index de7d51bb7..20245ee61 100644 --- a/lib/application/constants/animations.dart +++ b/lib/application/constants/animations.dart @@ -7,3 +7,5 @@ const terminalAnimationDuration = Duration(milliseconds: 300); const imageFadeDuration = Duration(seconds: 1); const textFieldHelperTextDuration = Duration(milliseconds: 150); + +const pageControllerAnimationDuration = Duration(milliseconds: 300); diff --git a/lib/application/constants/strings.dart b/lib/application/constants/strings.dart index 1c66c3cc1..4225821b9 100644 --- a/lib/application/constants/strings.dart +++ b/lib/application/constants/strings.dart @@ -75,10 +75,10 @@ const saveCollection = 'Salvar Coleção'; const collectionName = 'Nome da Coleção'; const collectionDescription = 'Descrição da Coleção'; -String updateMemoQuestionTitle(int memoIndex) => '### Pergunta $memoIndex'; +String memoQuestionTitle(int memoIndex) => '### Pergunta $memoIndex'; const updateMemoQuestionPlaceholder = 'Digite a questão'; -const updateMemoAnswer = '### Resposta'; +const memoAnswerTitle = '### Resposta'; const updateMemoAnswerPlaceholder = 'Digite a resposta'; const remove = 'Remover'; diff --git a/lib/application/pages/home/collections/update/update_collection_memos.dart b/lib/application/pages/home/collections/update/update_collection_memos.dart index 403fced41..ccbea453e 100644 --- a/lib/application/pages/home/collections/update/update_collection_memos.dart +++ b/lib/application/pages/home/collections/update/update_collection_memos.dart @@ -1,18 +1,145 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:layoutr/common_layout.dart'; +import 'package:memo/application/constants/animations.dart' as anims; import 'package:memo/application/constants/dimensions.dart' as dimens; import 'package:memo/application/constants/images.dart' as images; import 'package:memo/application/constants/strings.dart' as strings; import 'package:memo/application/theme/theme_controller.dart'; +import 'package:memo/application/view-models/home/update_collection_memos_vm.dart'; +import 'package:memo/application/view-models/home/update_collection_vm.dart'; +import 'package:memo/application/widgets/material/asset_icon_button.dart'; +import 'package:memo/application/widgets/theme/memo_terminal.dart'; +import 'package:memo/application/widgets/theme/rich_text_field.dart'; + +class UpdateCollectionMemos extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(updateCollectionMemosVM.notifier); + final state = ref.watch(updateCollectionMemosVM); + + final controller = usePageController(viewportFraction: 0.95); + final page = useState(0); + + useEffect(() { + void onPageUpdate() => page.value = controller.page!.toInt(); + + controller.addListener(onPageUpdate); + return () => controller.removeListener(onPageUpdate); + }); + + // Uses `PageView.custom` to support pages reordering. + final pagesView = PageView.custom( + controller: controller, + childrenDelegate: SliverChildBuilderDelegate( + (context, index) { + if (index == state.memos.length) { + return _CreateMemoEmptyState(onTap: vm.createEmptyMemo); + } + + final metadata = state.memos[index]; + + return _MemoPage( + key: ValueKey(metadata), + pageIndex: index, + metadata: metadata, + onRemove: state.memos.length > 1 ? () => vm.removeMemoAtIndex(index) : null, + ).withOnlyPadding(context, right: Spacing.xSmall); + }, + // Adds +1 to include creation empty state. + childCount: state.memos.length + 1, + findChildIndexCallback: (key) { + final valueKey = key as ValueKey; + return state.memos.indexOf(valueKey.value); + }, + ), + ); + + void onPreviousTapped() => + controller.previousPage(duration: anims.pageControllerAnimationDuration, curve: anims.defaultAnimationCurve); + void onNextTapped() => + controller.nextPage(duration: anims.pageControllerAnimationDuration, curve: anims.defaultAnimationCurve); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: pagesView), + context.verticalBox(Spacing.large), + _NavigationIndicator( + currentMemoIndex: page.value, + memosAmount: state.memos.length, + onLeftTapped: page.value > 0 ? onPreviousTapped : null, + onRightTapped: page.value < state.memos.length ? onNextTapped : null, + ), + ], + ).withSymmetricalPadding(context, vertical: Spacing.medium); + } +} + +class _MemoPage extends HookConsumerWidget { + const _MemoPage({required this.pageIndex, required this.metadata, this.onRemove, Key? key}) : super(key: key); + + final int pageIndex; + final MemoMetadata metadata; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(updateCollectionMemosVM.notifier); + final questionController = RichTextFieldController.fromValue(metadata.question); + final answerController = RichTextFieldController.fromValue(metadata.answer); + + useEffect(() { + void onQuestionUpdate() => + vm.updateMemoAtIndex(pageIndex, metadata: metadata.copyWith(question: questionController.value)); + void onAnswerUpdate() => + vm.updateMemoAtIndex(pageIndex, metadata: metadata.copyWith(answer: answerController.value)); + + questionController.addListener(onQuestionUpdate); + answerController.addListener(onAnswerUpdate); + + return () { + questionController.removeListener(onQuestionUpdate); + answerController.removeListener(onAnswerUpdate); + }; + }); + + return MemoTerminal( + memoIndex: pageIndex + 1, + questionController: questionController, + answerController: answerController, + onRemove: onRemove, + ); + } +} + +class _NavigationIndicator extends StatelessWidget { + const _NavigationIndicator({ + required this.currentMemoIndex, + required this.memosAmount, + this.onLeftTapped, + this.onRightTapped, + }); + + final int currentMemoIndex; + final int memosAmount; + + final VoidCallback? onLeftTapped; + final VoidCallback? onRightTapped; -class UpdateCollectionMemos extends StatelessWidget { @override Widget build(BuildContext context) { - return _CreateMemoEmptyState( - onTap: () { - print("teste"); - }, + final textTheme = Theme.of(context).textTheme; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AssetIconButton(images.chevronLeftAsset, onPressed: onLeftTapped), + Text('$currentMemoIndex/$memosAmount', style: textTheme.subtitle2), + AssetIconButton(images.chevronRightAsset, onPressed: onRightTapped), + ], ); } } @@ -29,7 +156,9 @@ class _CreateMemoEmptyState extends ConsumerWidget { final textTheme = Theme.of(context).textTheme; return Material( + borderRadius: dimens.executionsTerminalBorderRadius, child: InkWell( + borderRadius: dimens.executionsTerminalBorderRadius, onTap: onTap, child: Ink( decoration: BoxDecoration( @@ -37,25 +166,23 @@ class _CreateMemoEmptyState extends ConsumerWidget { borderRadius: dimens.executionsTerminalBorderRadius, color: theme.neutralSwatch.shade800, ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CustomPaint( - size: const Size(dimens.createMemoCtaSide, dimens.createMemoCtaSide), - painter: _CreateButtonPainter( - backgroundColor: theme.primarySwatch.shade400, - createColor: theme.neutralSwatch.shade800, - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CustomPaint( + size: const Size(dimens.createMemoCtaSide, dimens.createMemoCtaSide), + painter: _CreateButtonPainter( + backgroundColor: theme.primarySwatch.shade400, + createColor: theme.neutralSwatch.shade800, ), - context.verticalBox(Spacing.small), - Text( - strings.newMemo.toUpperCase(), - style: textTheme.button?.copyWith(color: theme.primarySwatch.shade400), - textAlign: TextAlign.center, - ) - ], - ), + ), + context.verticalBox(Spacing.small), + Text( + strings.newMemo.toUpperCase(), + style: textTheme.button?.copyWith(color: theme.primarySwatch.shade400), + textAlign: TextAlign.center, + ) + ], ), ), ), diff --git a/lib/application/pages/home/collections/update/update_collection_page.dart b/lib/application/pages/home/collections/update/update_collection_page.dart index aba9159fe..baf45371f 100644 --- a/lib/application/pages/home/collections/update/update_collection_page.dart +++ b/lib/application/pages/home/collections/update/update_collection_page.dart @@ -9,6 +9,7 @@ import 'package:memo/application/pages/home/collections/update/update_collection import 'package:memo/application/pages/home/collections/update/update_collection_providers.dart'; import 'package:memo/application/theme/theme_controller.dart'; import 'package:memo/application/view-models/home/update_collection_details_vm.dart'; +import 'package:memo/application/view-models/home/update_collection_memos_vm.dart'; import 'package:memo/application/view-models/home/update_collection_vm.dart'; import 'package:memo/application/widgets/theme/custom_button.dart'; import 'package:memo/application/widgets/theme/exception_retry_container.dart'; @@ -42,7 +43,6 @@ class UpdateCollectionPage extends HookConsumerWidget { children: [ ThemedTabBar(controller: tabController, tabs: tabs), Expanded(child: _UpdateCollectionContents(selectedSegment: selectedSegment.value)), - context.verticalBox(Spacing.large), _BottomActionContainer( onSegmentSwapRequested: (segment) => tabController.animateTo(_Segment.values.indexOf(segment)), selectedSegment: selectedSegment.value, @@ -72,17 +72,24 @@ class _UpdateCollectionContents extends ConsumerWidget { } if (state is UpdateCollectionLoaded) { + late Widget body; + switch (selectedSegment) { case _Segment.details: - return ProviderScope( - overrides: [ - updateDetailsMetadata.overrideWithValue(state.collectionMetadata), - ], - child: _UpdateCollectionDetails(), - ); + body = _UpdateCollectionDetails(); + break; case _Segment.memos: - return UpdateCollectionMemos(); + body = _UpdateCollectionMemos(); + break; } + + return ProviderScope( + overrides: [ + updateDetailsMetadata.overrideWithValue(state.collectionMetadata), + updateMemosMetadata.overrideWithValue(state.memosMetadata), + ], + child: body, + ); } throw InconsistentStateError.layout('Unsupported subtype (${state.runtimeType}) of `UpdateCollectionState`'); @@ -103,6 +110,17 @@ class _UpdateCollectionDetails extends ConsumerWidget { } } +class _UpdateCollectionMemos extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(updateCollectionVM.notifier); + + ref.listen(updateCollectionMemosVM, (_, state) => vm.updateMemos(memos: state.memos)); + + return UpdateCollectionMemos(); + } +} + extension on _Segment { String get title { switch (this) { diff --git a/lib/application/pages/home/collections/update/update_collection_providers.dart b/lib/application/pages/home/collections/update/update_collection_providers.dart index 43ee29cf8..29f2d9ea1 100644 --- a/lib/application/pages/home/collections/update/update_collection_providers.dart +++ b/lib/application/pages/home/collections/update/update_collection_providers.dart @@ -8,3 +8,7 @@ final updateCollectionId = Provider((_) => throw UnimplementedError(), /// Overridable collection metadata used in the scope of a collection update. final updateDetailsMetadata = Provider((_) => throw UnimplementedError(), name: 'updateDetailsMetadata'); + +/// Overridable memos metadata used in the scope of a collection update. +final updateMemosMetadata = + Provider>((_) => throw UnimplementedError(), name: 'updateMemosMetadata'); diff --git a/lib/application/view-models/home/update_collection_memos_vm.dart b/lib/application/view-models/home/update_collection_memos_vm.dart new file mode 100644 index 000000000..92c025240 --- /dev/null +++ b/lib/application/view-models/home/update_collection_memos_vm.dart @@ -0,0 +1,72 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:memo/application/pages/home/collections/update/update_collection_providers.dart'; +import 'package:memo/application/view-models/home/update_collection_vm.dart'; + +final updateCollectionMemosVM = StateNotifierProvider.autoDispose( + (ref) => UpdateCollectionMemosVMImpl(memos: ref.read(updateMemosMetadata)), + dependencies: [updateMemosMetadata], + name: 'updateCollectionMemosVM', +); + +abstract class UpdateCollectionMemosVM extends StateNotifier { + UpdateCollectionMemosVM(UpdateMemosState state) : super(state); + + /// Replaces the current saved memos with [memos]. + void updateMemos(List memos); + + /// Creates a new empty Memo in the collection. + void createEmptyMemo(); + + /// Updates memo at [index] metadata with [metadata]. + void updateMemoAtIndex(int index, {required MemoMetadata metadata}); + + /// Removes the memo at given [index]. + /// + /// If [index] is not in range `0 <= index < memos.length` does nothing. + void removeMemoAtIndex(int index); +} + +class UpdateCollectionMemosVMImpl extends UpdateCollectionMemosVM { + UpdateCollectionMemosVMImpl({required List memos}) : super(UpdateMemosState(memos: memos)); + + @override + void updateMemos(List memos) => state = state.copyWith(memos: memos); + + @override + void createEmptyMemo() { + final updatedMemos = List.from(state.memos)..add(MemoMetadata.empty()); + state = state.copyWith(memos: updatedMemos); + } + + @override + void updateMemoAtIndex(int index, {required MemoMetadata metadata}) { + final updatedMemos = state.memos + ..removeAt(index) + ..insert(index, metadata); + + state = state.copyWith(memos: updatedMemos); + } + + @override + void removeMemoAtIndex(int index) { + if (index < 0 || index >= state.memos.length) { + return; + } + + final updatedMemos = List.from(state.memos)..removeAt(index); + state = state.copyWith(memos: updatedMemos); + } +} + +class UpdateMemosState extends Equatable { + const UpdateMemosState({required this.memos}); + + final List memos; + + UpdateMemosState copyWith({required List memos}) => UpdateMemosState(memos: memos); + + @override + List get props => [memos]; +} diff --git a/lib/application/view-models/home/update_collection_vm.dart b/lib/application/view-models/home/update_collection_vm.dart index 685ee5b16..89d13be73 100644 --- a/lib/application/view-models/home/update_collection_vm.dart +++ b/lib/application/view-models/home/update_collection_vm.dart @@ -27,8 +27,13 @@ abstract class UpdateCollectionVM extends StateNotifier { /// Updates collection details metadata. /// - /// To persist the collection updates, use [saveCollection]. - void updateMetadata({CollectionMetadata? metadata}); + /// To persist the collection use [saveCollection]. + void updateMetadata({required CollectionMetadata metadata}); + + /// Updates collection memos metadata. + /// + /// To persist the collection use [saveCollection]. + void updateMemos({required List memos}); /// Save the created/edited collection. /// @@ -71,6 +76,9 @@ class UpdateCollectionVMImpl extends UpdateCollectionVM { @override void updateMetadata({CollectionMetadata? metadata}) => state = loadedState.copyWith(metadata: metadata); + @override + void updateMemos({List? memos}) => state = loadedState.copyWith(memos: memos); + @override Future saveCollection() async { final loadedState = state as UpdateCollectionLoaded; @@ -116,10 +124,13 @@ class CollectionMetadata extends Equatable { class MemoMetadata extends Equatable { const MemoMetadata({required this.question, required this.answer}); - factory MemoMetadata.empty() => const MemoMetadata(question: '', answer: ''); + factory MemoMetadata.empty() => const MemoMetadata(question: RichTextEditingValue(), answer: RichTextEditingValue()); + + MemoMetadata copyWith({RichTextEditingValue? question, RichTextEditingValue? answer}) => + MemoMetadata(question: question ?? this.question, answer: answer ?? this.answer); - final String question; - final String answer; + final RichTextEditingValue question; + final RichTextEditingValue answer; @override List get props => [question, answer]; diff --git a/lib/application/widgets/theme/memo_terminal.dart b/lib/application/widgets/theme/memo_terminal.dart index 05746dd0f..101f26e12 100644 --- a/lib/application/widgets/theme/memo_terminal.dart +++ b/lib/application/widgets/theme/memo_terminal.dart @@ -45,7 +45,7 @@ class MemoTerminal extends HookConsumerWidget { final questionController = this.questionController ?? useRichTextEditingController(); final questionTitle = Text( - strings.updateMemoQuestionTitle(memoIndex), + strings.memoQuestionTitle(memoIndex), style: textTheme.bodyText1?.copyWith(color: theme.secondarySwatch), ); final questionField = Column( @@ -63,7 +63,7 @@ class MemoTerminal extends HookConsumerWidget { final answerController = this.answerController ?? useRichTextEditingController(); final answerTitle = Text( - strings.updateMemoAnswer, + strings.memoAnswerTitle, style: textTheme.bodyText1?.copyWith(color: theme.primarySwatch), ); final answerField = Column( diff --git a/lib/application/widgets/theme/rich_text_field.dart b/lib/application/widgets/theme/rich_text_field.dart index 8363f7305..df5d17a32 100644 --- a/lib/application/widgets/theme/rich_text_field.dart +++ b/lib/application/widgets/theme/rich_text_field.dart @@ -49,6 +49,8 @@ class RichTextFieldController extends ValueNotifier { ), ); + RichTextFieldController.fromValue(RichTextEditingValue value) : super(value); + String get richText => value.richText; String get plainText => value.plainText; TextSelection get selection => value.selection;