diff --git a/kitchenowl/lib/cubits/auth_cubit.dart b/kitchenowl/lib/cubits/auth_cubit.dart index 9f0cf0a1..751f73e1 100644 --- a/kitchenowl/lib/cubits/auth_cubit.dart +++ b/kitchenowl/lib/cubits/auth_cubit.dart @@ -141,10 +141,12 @@ class AuthCubit extends Cubit { } } - void onboard({ + Future onboard({ required String username, required String name, required String password, + Function()? wrongCredentialsCallback, + Function()? correctCredentialsCallback, }) async { emit(const Loading()); if (await ApiService.getInstance().isOnboarding()) { @@ -152,8 +154,15 @@ class AuthCubit extends Cubit { await ApiService.getInstance().onboarding(username, name, password); if (token != null && ApiService.getInstance().isAuthenticated()) { await SecureStorage.getInstance().write(key: 'TOKEN', value: token); + await this.stream.any((s) => s is Authenticated); + if (correctCredentialsCallback != null) { + correctCredentialsCallback(); + } } else { - updateState(); + await updateState(); + if (wrongCredentialsCallback != null) { + wrongCredentialsCallback(); + } } } } @@ -164,6 +173,7 @@ class AuthCubit extends Cubit { required String password, required String email, Function(String?)? wrongCredentialsCallback, + Function()? correctCredentialsCallback, }) async { emit(const Loading()); final (token, msg) = await ApiService.getInstance().signup( @@ -174,6 +184,10 @@ class AuthCubit extends Cubit { ); if (token != null && ApiService.getInstance().isAuthenticated()) { await SecureStorage.getInstance().write(key: 'TOKEN', value: token); + await this.stream.any((s) => s is Authenticated); + if (correctCredentialsCallback != null) { + correctCredentialsCallback(); + } } else { await updateState(); if (ApiService.getInstance().connectionStatus == Connection.connected && diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index 8d6cd25b..3193784e 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -120,6 +120,11 @@ "@go": {}, "@grid": {}, "@helpTranslate": {}, + "@hi": { + "placeholders": { + "name": {} + } + }, "@household": {}, "@householdDelete": {}, "@householdDeleteConfirmation": { @@ -316,6 +321,7 @@ } }, "@signup": {}, + "@skip": {}, "@smaller": {}, "@sortingAlgorithmic": {}, "@sortingAlphabetical": {}, @@ -339,6 +345,14 @@ "@themeSystem": {}, "@total": {}, "@totalTime": {}, + "@tutorialItemDescription1": {}, + "@tutorialItemDescription2": {}, + "@tutorialRecipeDescription": {}, + "@tutorialRecipeMore": { + "placeholders": { + "url": {} + } + }, "@uncategorized": {}, "@underConstruction": {}, "@undo": {}, @@ -459,6 +473,7 @@ "go": "Go", "grid": "Grid", "helpTranslate": "Help translate", + "hi": "Hi {name}!", "household": "Household", "householdDelete": "Delete household", "householdDeleteConfirmation": "Are you sure you want to delete {household}? This will delete any items, recipes, and expenses associated with that household.", @@ -593,6 +608,7 @@ "shoppingLists": "Shopping lists", "signInWith": "Sign in with {provider}", "signup": "Sign up", + "skip": "Skip", "smaller": "Smaller", "sortingAlgorithmic": "Algorithmic", "sortingAlphabetical": "Alphabetical", @@ -612,6 +628,10 @@ "themeSystem": "System", "total": "Total", "totalTime": "Total time", + "tutorialItemDescription1": "When searching for items you can add an amount at the start of your query and it will be added as a description.", + "tutorialItemDescription2": "Or, if amounts are not enough, use a comma to seperate the item name and description.", + "tutorialRecipeDescription": "Inside of recipes you can use Markdown. Basic features are headlines specified with hash signs, numbered lists for instruction steps, and references to ingredients using an at sign and the ingredient name.", + "tutorialRecipeMore": "For more information visit the [documentation]({url}).", "uncategorized": "Uncategorized", "underConstruction": "Under construction", "undo": "Undo", diff --git a/kitchenowl/lib/pages/expense_page.dart b/kitchenowl/lib/pages/expense_page.dart index 9ca97231..5c57a140 100644 --- a/kitchenowl/lib/pages/expense_page.dart +++ b/kitchenowl/lib/pages/expense_page.dart @@ -1,13 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:intl/intl.dart'; import 'package:kitchenowl/app.dart'; import 'package:kitchenowl/cubits/expense_cubit.dart'; import 'package:kitchenowl/enums/update_enum.dart'; -import 'package:kitchenowl/helpers/url_launcher.dart'; import 'package:kitchenowl/models/expense.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/pages/expense_add_update_page.dart'; @@ -113,31 +110,8 @@ class _ExpensePageState extends State { margin: const EdgeInsets.fromLTRB(16, 24, 16, 4), child: Padding( padding: const EdgeInsets.all(16), - child: MarkdownBody( + child: KitchenOwlMarkdownBody( data: state.expense.description!, - shrinkWrap: true, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context), - ).copyWith( - blockquoteDecoration: BoxDecoration( - color: Theme.of(context).cardTheme.color ?? - Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(2.0), - ), - ), - imageBuilder: (uri, title, alt) => - CachedNetworkImage( - imageUrl: uri.toString(), - placeholder: (context, url) => - const CircularProgressIndicator(), - errorWidget: (context, url, error) => - const Icon(Icons.error), - ), - onTapLink: (text, href, title) { - if (href != null && isValidUrl(href)) { - openUrl(context, href); - } - }, ), ), ), diff --git a/kitchenowl/lib/pages/onboarding_page.dart b/kitchenowl/lib/pages/onboarding_page.dart index f74ce37e..eb935904 100644 --- a/kitchenowl/lib/pages/onboarding_page.dart +++ b/kitchenowl/lib/pages/onboarding_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:kitchenowl/cubits/auth_cubit.dart'; import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/router.dart'; import 'package:kitchenowl/widgets/create_user_form_fields.dart'; class OnboardingPage extends StatefulWidget { @@ -44,7 +45,7 @@ class _OnboardingPageState extends State { ), Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), - child: ElevatedButton( + child: LoadingElevatedButton( onPressed: () => _submit(context), child: Text(AppLocalizations.of(context)!.start), ), @@ -67,12 +68,13 @@ class _OnboardingPageState extends State { ); } - void _submit(BuildContext context) { + Future _submit(BuildContext context) async { if (_formKey.currentState!.validate()) { - BlocProvider.of(context).onboard( + await BlocProvider.of(context).onboard( username: usernameController.text, name: nameController.text, password: passwordController.text, + correctCredentialsCallback: () => router.push("/tutorial"), ); } } diff --git a/kitchenowl/lib/pages/recipe_page.dart b/kitchenowl/lib/pages/recipe_page.dart index b721240d..19e9c9f8 100644 --- a/kitchenowl/lib/pages/recipe_page.dart +++ b/kitchenowl/lib/pages/recipe_page.dart @@ -1,24 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:kitchenowl/app.dart'; import 'package:kitchenowl/cubits/recipe_cubit.dart'; import 'package:kitchenowl/enums/update_enum.dart'; -import 'package:kitchenowl/helpers/recipe_item_markdown_extension.dart'; import 'package:kitchenowl/helpers/share.dart'; -import 'package:kitchenowl/helpers/url_launcher.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/item.dart'; import 'package:kitchenowl/models/recipe.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; import 'package:kitchenowl/pages/recipe_add_update_page.dart'; import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/widgets/recipe_markdown_body.dart'; import 'package:kitchenowl/widgets/recipe_source_chip.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:kitchenowl/widgets/sliver_with_pinned_footer.dart'; -import 'package:markdown/markdown.dart' as md; import 'package:responsive_builder/responsive_builder.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:tuple/tuple.dart'; @@ -129,44 +125,8 @@ class _RecipePageState extends State { ), if (state.recipe.prepTime + state.recipe.cookTime > 0) const SizedBox(height: 16), - MarkdownBody( - data: state.recipe.description, - shrinkWrap: true, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context), - ).copyWith( - blockquoteDecoration: BoxDecoration( - color: Theme.of(context).cardTheme.color ?? - Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(2.0), - ), - ), - imageBuilder: (uri, title, alt) => CachedNetworkImage( - imageUrl: uri.toString(), - placeholder: (context, url) => - const CircularProgressIndicator(), - errorWidget: (context, url, error) => - const Icon(Icons.error), - ), - onTapLink: (text, href, title) { - if (href != null && isValidUrl(href)) { - openUrl(context, href); - } - }, - builders: { - 'recipeItem': RecipeItemMarkdownBuilder( - cubit: cubit, - ), - }, - extensionSet: md.ExtensionSet( - md.ExtensionSet.gitHubWeb.blockSyntaxes, - md.ExtensionSet.gitHubWeb.inlineSyntaxes + - [ - RecipeItemMarkdownSyntax( - state.recipe, - ), - ], - ), + RecipeMarkdownBody( + recipe: state.recipe, ), ], ), diff --git a/kitchenowl/lib/pages/signup_page.dart b/kitchenowl/lib/pages/signup_page.dart index 1c39f5e2..15b9efe3 100644 --- a/kitchenowl/lib/pages/signup_page.dart +++ b/kitchenowl/lib/pages/signup_page.dart @@ -124,15 +124,12 @@ class _SignupPageState extends State { isValidUrl(privacyPolicyUrl)) Padding( padding: const EdgeInsets.only(top: 16), - child: MarkdownBody( + child: KitchenOwlMarkdownBody( data: AppLocalizations.of(context)! .privacyPolicyAgree( "[${AppLocalizations.of(context)!.privacyPolicy}]($privacyPolicyUrl)", ), - shrinkWrap: true, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context), - ).copyWith( + styleSheet: MarkdownStyleSheet( p: Theme.of(context) .textTheme .labelSmall @@ -148,11 +145,6 @@ class _SignupPageState extends State { Theme.of(context).colorScheme.primary, ), ), - onTapLink: (text, href, title) { - if (href != null && isValidUrl(href)) { - openUrl(context, href); - } - }, ), ), Padding( @@ -198,6 +190,8 @@ class _SignupPageState extends State { Future.delayed( Duration(milliseconds: 1), () => router.go("/register")); }, + correctCredentialsCallback: () => Future.delayed( + Duration(milliseconds: 1), () => router.push("/tutorial")), ); } } diff --git a/kitchenowl/lib/pages/tutorial_page.dart b/kitchenowl/lib/pages/tutorial_page.dart new file mode 100644 index 00000000..e6482af0 --- /dev/null +++ b/kitchenowl/lib/pages/tutorial_page.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:kitchenowl/cubits/auth_cubit.dart'; +import 'package:kitchenowl/item_icons.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/item.dart'; +import 'package:kitchenowl/models/recipe.dart'; +import 'package:kitchenowl/widgets/recipe_markdown_body.dart'; +import 'package:kitchenowl/widgets/shopping_item.dart'; +import 'package:markdown/markdown.dart' as md; + +class TutorialPage extends StatefulWidget { + const TutorialPage({super.key}); + + @override + State createState() => _TutorialPageState(); +} + +class _TutorialPageState extends State { + int step = 0; + + static const int lastStep = 2; + + static const String _tutorialMarkdown = + "## Instructions\n1. First use @eggs\n2. Now stir"; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints.expand(width: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + tween: Tween( + begin: 1 / (lastStep + 1), + end: (step + 1) / (lastStep + 1), + ), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(30), + ), + ), + ), + Text( + AppLocalizations.of(context)!.hi( + BlocProvider.of(context).getUser()?.name ?? + ""), + style: Theme.of(context).textTheme.displayMedium, + textAlign: TextAlign.start, + ), + Expanded( + child: AbsorbPointer( + absorbing: step != 2, + child: IndexedStack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: Text(AppLocalizations.of(context)! + .tutorialItemDescription1), + flex: 2, + ), + SearchTextField( + controller: TextEditingController.fromValue( + TextEditingValue(text: "300g Tomatoes")), + onSearch: (s) => Future.value(), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth / 3, + child: AspectRatio( + aspectRatio: 1, + child: ShoppingItemWidget( + item: ItemWithDescription( + name: "Tomatoes", + description: "300g", + icon: "tomato", + ), + gridStyle: true, + selected: true, + ), + ), + ), + ), + const Spacer(flex: 3), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: Text(AppLocalizations.of(context)! + .tutorialItemDescription2), + flex: 2, + ), + SearchTextField( + controller: TextEditingController.fromValue( + TextEditingValue( + text: "Raspberry, Frozen")), + onSearch: (s) => Future.value(), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) => SizedBox( + width: constraints.maxWidth / 3, + child: AspectRatio( + aspectRatio: 1, + child: ShoppingItemWidget( + item: ItemWithDescription( + name: "Raspberry", + description: "Frozen", + icon: "raspberry", + ), + gridStyle: true, + selected: true, + ), + ), + ), + ), + const Spacer(flex: 3), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: Text(AppLocalizations.of(context)! + .tutorialRecipeDescription), + flex: 2, + ), + Row( + children: [ + Expanded( + child: Text( + "Markdown", + style: Theme.of(context) + .textTheme + .labelMedium! + .copyWith( + color: Theme.of(context) + .textTheme + .labelMedium + ?.color + ?.withAlpha(85), + ), + )), + Text( + AppLocalizations.of(context)!.recipes, + style: Theme.of(context) + .textTheme + .labelMedium! + .copyWith( + color: Theme.of(context) + .textTheme + .labelMedium + ?.color + ?.withAlpha(85), + ), + ), + ], + ), + const SizedBox(height: 15), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text(_tutorialMarkdown)), + SizedBox( + height: 120, + width: 60, + child: VerticalDivider(), + ), + Expanded( + child: RecipeMarkdownBody( + recipeItemBuilder: + _TutorialRecipeItemMarkdownBuilder( + RecipeItem( + name: "Eggs", + description: "2", + icon: "eggs", + ), + ), + recipe: Recipe( + description: _tutorialMarkdown, + items: [ + RecipeItem( + name: "Eggs", + description: "2", + icon: "eggs", + ), + ], + ), + ), + ), + ], + ), + const Spacer(flex: 1), + KitchenOwlMarkdownBody( + data: AppLocalizations.of(context)! + .tutorialRecipeMore( + "https://docs.kitchenowl.org/latest/Tips-%26-Tricks/markdown/", + ), + styleSheet: MarkdownStyleSheet( + p: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + color: Theme.of(context) + .textTheme + .labelMedium + ?.color + ?.withAlpha(76), + ), + a: TextStyle( + color: + Theme.of(context).colorScheme.primary, + ), + ), + ), + const Spacer(flex: 1), + ], + ) + ], + index: step, + ), + ), + ), + Row( + children: [ + TextButton( + onPressed: () { + if (step == 0) + Navigator.of(context).pop(); + else + setState(() { + step--; + }); + }, + child: Text( + step == 0 + ? AppLocalizations.of(context)!.skip + : AppLocalizations.of(context)!.back, + ), + ), + const Spacer(), + ElevatedButton( + onPressed: () { + if (step < lastStep) + setState(() { + step++; + }); + else + Navigator.of(context).pop(); + }, + child: Text( + step < lastStep + ? AppLocalizations.of(context)!.next + : AppLocalizations.of(context)!.okay, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TutorialRecipeItemMarkdownBuilder extends MarkdownElementBuilder { + final RecipeItem item; + _TutorialRecipeItemMarkdownBuilder(this.item); + + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + IconData? icon = ItemIcons.get(item); + return RichText( + text: TextSpan(children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Chip( + avatar: icon != null ? Icon(icon) : null, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + labelPadding: icon != null + ? const EdgeInsets.only(left: 1, right: 4) + : null, + label: Text(item.name + + (item.description.isNotEmpty + ? " (${item.description})" + : "")), + )), + ]), + ); + } +} diff --git a/kitchenowl/lib/router.dart b/kitchenowl/lib/router.dart index c9da5eb8..d6df6333 100644 --- a/kitchenowl/lib/router.dart +++ b/kitchenowl/lib/router.dart @@ -28,6 +28,7 @@ import 'package:kitchenowl/pages/settings_user_page.dart'; import 'package:kitchenowl/pages/setup_page.dart'; import 'package:kitchenowl/pages/signup_page.dart'; import 'package:kitchenowl/pages/splash_page.dart'; +import 'package:kitchenowl/pages/tutorial_page.dart'; import 'package:kitchenowl/pages/unreachable_page.dart'; import 'package:kitchenowl/pages/household_page.dart'; import 'package:kitchenowl/pages/unsupported_page.dart'; @@ -184,6 +185,15 @@ final router = GoRouter( return (authState is! Unreachable) ? "/" : null; }, ), + GoRoute( + path: '/tutorial', + pageBuilder: (context, state) => SharedAxisTransitionPage( + key: state.pageKey, + name: state.name, + transitionType: SharedAxisTransitionType.scaled, + child: const TutorialPage(), + ), + ), GoRoute( path: "/household", pageBuilder: (context, state) => SharedAxisTransitionPage( diff --git a/kitchenowl/lib/widgets/_export.dart b/kitchenowl/lib/widgets/_export.dart index c483aabe..bd4ce770 100644 --- a/kitchenowl/lib/widgets/_export.dart +++ b/kitchenowl/lib/widgets/_export.dart @@ -30,3 +30,4 @@ export 'loading_list_tile.dart'; export 'loading_elevated_button_icon.dart'; export 'loading_filled_button.dart'; export 'kitchenowl_search_anchor.dart'; +export 'kitchenowl_markdown_body.dart'; diff --git a/kitchenowl/lib/widgets/kitchenowl_markdown_body.dart b/kitchenowl/lib/widgets/kitchenowl_markdown_body.dart new file mode 100644 index 00000000..f7022a06 --- /dev/null +++ b/kitchenowl/lib/widgets/kitchenowl_markdown_body.dart @@ -0,0 +1,51 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:kitchenowl/helpers/url_launcher.dart'; + +class KitchenOwlMarkdownBody extends StatelessWidget { + final String data; + final Map builders; + final MarkdownStyleSheet? styleSheet; + final md.ExtensionSet? extensionSet; + + const KitchenOwlMarkdownBody({ + super.key, + required this.data, + this.builders = const {}, + this.styleSheet, + this.extensionSet, + }); + + @override + Widget build(BuildContext context) { + return MarkdownBody( + data: data, + shrinkWrap: true, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ) + .copyWith( + blockquoteDecoration: BoxDecoration( + color: Theme.of(context).cardTheme.color ?? + Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(2.0), + ), + ) + .merge(styleSheet), + imageBuilder: (uri, title, alt) => CachedNetworkImage( + imageUrl: uri.toString(), + placeholder: (context, url) => const CircularProgressIndicator(), + errorWidget: (context, url, error) => const Icon(Icons.error), + ), + onTapLink: (text, href, title) { + if (href != null && isValidUrl(href)) { + openUrl(context, href); + } + }, + builders: builders, + extensionSet: extensionSet, + ); + } +} diff --git a/kitchenowl/lib/widgets/recipe_markdown_body.dart b/kitchenowl/lib/widgets/recipe_markdown_body.dart new file mode 100644 index 00000000..3aa4ec23 --- /dev/null +++ b/kitchenowl/lib/widgets/recipe_markdown_body.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:kitchenowl/cubits/recipe_cubit.dart'; +import 'package:kitchenowl/helpers/recipe_item_markdown_extension.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/recipe.dart'; +import 'package:markdown/markdown.dart' as md; + +class RecipeMarkdownBody extends StatelessWidget { + final Recipe recipe; + final MarkdownElementBuilder? recipeItemBuilder; + + const RecipeMarkdownBody({ + super.key, + required this.recipe, + this.recipeItemBuilder, + }); + + @override + Widget build(BuildContext context) { + return KitchenOwlMarkdownBody( + data: recipe.description, + builders: { + 'recipeItem': recipeItemBuilder ?? + RecipeItemMarkdownBuilder( + cubit: BlocProvider.of(context), + ), + }, + extensionSet: md.ExtensionSet( + md.ExtensionSet.gitHubWeb.blockSyntaxes, + md.ExtensionSet.gitHubWeb.inlineSyntaxes + + [ + RecipeItemMarkdownSyntax(recipe), + ], + ), + ); + } +}