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 Nov 22, 2021
1 parent 9f86cba commit 9d88937
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 43 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,63 +1,189 @@
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 StatelessWidget {
class UpdateCollectionMemos extends HookConsumerWidget {
@override
Widget build(BuildContext context) {
return _CreateMemoEmptyState(
onTap: () {
// TODO(ggirotto): Connect with the VM when available
},
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);
}
}

/// An empty state call-to-action to add a new `Memo` to its `Collection`.
class _CreateMemoEmptyState extends ConsumerWidget {
const _CreateMemoEmptyState({this.onTap});
class _MemoPage extends HookConsumerWidget {
const _MemoPage({required this.pageIndex, required this.metadata, this.onRemove, Key? key}) : super(key: key);

final VoidCallback? onTap;
final int pageIndex;
final MemoMetadata metadata;
final VoidCallback? onRemove;

@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeController);
final textTheme = Theme.of(context).textTheme;

final roundedPlusIcon = CustomPaint(
size: const Size(dimens.createMemoCtaSide, dimens.createMemoCtaSide),
painter: _CreateButtonPainter(
backgroundColor: theme.primarySwatch.shade400,
createColor: theme.neutralSwatch.shade800,
),
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 createMemoCta = Column(
final VoidCallback? onLeftTapped;
final VoidCallback? onRightTapped;

@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;

return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
roundedPlusIcon,
context.verticalBox(Spacing.small),
Text(
strings.newMemo.toUpperCase(),
style: textTheme.button?.copyWith(color: theme.primarySwatch.shade400),
textAlign: TextAlign.center,
)
AssetIconButton(images.chevronLeftAsset, onPressed: onLeftTapped),
Text('$currentMemoIndex/$memosAmount', style: textTheme.subtitle2),
AssetIconButton(images.chevronRightAsset, onPressed: onRightTapped),
],
);
}
}

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

final VoidCallback? onTap;

@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeController);
final textTheme = Theme.of(context).textTheme;

return Material(
borderRadius: dimens.executionsTerminalBorderRadius,
child: InkWell(
borderRadius: dimens.executionsTerminalBorderRadius,
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
border: Border.all(color: theme.neutralSwatch.shade700, width: dimens.genericBorderHeight),
borderRadius: dimens.executionsTerminalBorderRadius,
color: theme.neutralSwatch.shade800,
),
child: Center(child: createMemoCta),
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,
)
],
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`');
Expand All @@ -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<UpdateMemosState>(updateCollectionMemosVM, (_, state) => vm.updateMemos(memos: state.memos));

return UpdateCollectionMemos();
}
}

extension on _Segment {
String get title {
switch (this) {
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];
}
Loading

0 comments on commit 9d88937

Please sign in to comment.