Skip to content

Commit

Permalink
Adds Memos List - closes #221
Browse files Browse the repository at this point in the history
  • Loading branch information
ggirotto committed Dec 8, 2021
1 parent 18d647c commit 21ed704
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 19 deletions.
2 changes: 2 additions & 0 deletions lib/application/constants/animations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ const terminalAnimationDuration = Duration(milliseconds: 300);

const imageFadeDuration = Duration(seconds: 1);
const textFieldHelperTextDuration = Duration(milliseconds: 150);

const pageControllerAnimationDuration = Duration(milliseconds: 300);
Original file line number Diff line number Diff line change
@@ -1,23 +1,150 @@
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<MemoMetadata>;
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: () {
// TODO(ggirotto): Connect with the VM when available
},
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),
],
);
}
}

/// An empty state call-to-action to add a new `Memo` to its `Collection`.
/// An empty state CTA to add a new `Memo` to a `Collection`.
class _CreateMemoEmptyState extends ConsumerWidget {
const _CreateMemoEmptyState({this.onTap});

Expand All @@ -42,7 +169,9 @@ class _CreateMemoEmptyState extends ConsumerWidget {
);

return Material(
borderRadius: dimens.executionsTerminalBorderRadius,
child: InkWell(
borderRadius: dimens.executionsTerminalBorderRadius,
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,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,
Expand Down Expand Up @@ -71,17 +70,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`');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ final updateCollectionId = Provider<String?>((_) => throw UnimplementedError(),
/// Overridable collection metadata used in the scope of a collection update.
final updateDetailsMetadata =
Provider<CollectionMetadata>((_) => throw UnimplementedError(), name: 'updateDetailsMetadata');

/// Overridable memos metadata used in the scope of a collection update.
final updateMemosMetadata =
Provider<List<MemoMetadata>>((_) => throw UnimplementedError(), name: 'updateMemosMetadata');
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class UpdateMemoTerminal extends HookConsumerWidget {
final textTheme = Theme.of(context).textTheme;

final questionTitle = Text(
strings.updateMemoQuestionTitle(memoIndex),
strings.memoQuestionTitle(memoIndex),
style: textTheme.bodyText1?.copyWith(color: theme.secondarySwatch),
);
final questionField = Column(
Expand All @@ -57,7 +57,7 @@ class UpdateMemoTerminal extends HookConsumerWidget {
);

final answerTitle = Text(
strings.updateMemoAnswer,
strings.memoAnswerTitle,
style: textTheme.bodyText1?.copyWith(color: theme.primarySwatch),
);
final answerField = Column(
Expand Down
72 changes: 72 additions & 0 deletions lib/application/view-models/home/update_collection_memos_vm.dart
Original file line number Diff line number Diff line change
@@ -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<UpdateCollectionMemosVM, UpdateMemosState>(
(ref) => UpdateCollectionMemosVMImpl(memos: ref.read(updateMemosMetadata)),
dependencies: [updateMemosMetadata],
name: 'updateCollectionMemosVM',
);

abstract class UpdateCollectionMemosVM extends StateNotifier<UpdateMemosState> {
UpdateCollectionMemosVM(UpdateMemosState state) : super(state);

/// Replaces the current saved memos with [memos].
void updateMemos(List<MemoMetadata> 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<MemoMetadata> memos}) : super(UpdateMemosState(memos: memos));

@override
void updateMemos(List<MemoMetadata> memos) => state = state.copyWith(memos: memos);

@override
void createEmptyMemo() {
final updatedMemos = List<MemoMetadata>.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<MemoMetadata>.from(state.memos)..removeAt(index);
state = state.copyWith(memos: updatedMemos);
}
}

class UpdateMemosState extends Equatable {
const UpdateMemosState({required this.memos});

final List<MemoMetadata> memos;

UpdateMemosState copyWith({required List<MemoMetadata> memos}) => UpdateMemosState(memos: memos);

@override
List<Object?> get props => [memos];
}
17 changes: 14 additions & 3 deletions lib/application/view-models/home/update_collection_vm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ abstract class UpdateCollectionVM extends StateNotifier<UpdateCollectionState> {
/// To persist the collection updates, use [saveCollection].
void updateMetadata({required CollectionMetadata metadata});

/// Updates collection memos metadata.
///
/// To persist the collection use [saveCollection].
void updateMemos({required List<MemoMetadata> memos});

/// Save the created/edited collection.
///
/// Emits [UpdateCollectionFailedSaving] if it fails to save the collection.
Expand Down Expand Up @@ -71,6 +76,9 @@ class UpdateCollectionVMImpl extends UpdateCollectionVM {
void updateMetadata({required CollectionMetadata metadata}) =>
state = loadedState.copyWith(metadata: metadata, hasValidDetails: _validateDetails(metadata: metadata));

@override
void updateMemos({List<MemoMetadata>? memos}) => state = loadedState.copyWith(memos: memos);

@override
Future<void> saveCollection() async {
final loadedState = state as UpdateCollectionLoaded;
Expand Down Expand Up @@ -129,10 +137,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<Object?> get props => [question, answer];
Expand Down

0 comments on commit 21ed704

Please sign in to comment.