1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-08 13:58:47 +00:00

Adds ai_recipe_generation sample (#2242)

Adding the demo app from my I/O talk. Because AI.

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

---------

Co-authored-by: Brett Morgan <brett.morgan@gmail.com>
This commit is contained in:
Eric Windmill
2024-05-14 11:41:20 -04:00
committed by GitHub
parent 8575261d37
commit be52906894
171 changed files with 8626 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
import 'package:ai_recipe_generation/util/extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'features/prompt/widgets/app_info_dialog_widget.dart';
import 'theme.dart';
import 'widgets/appbar_shape_border.dart';
class AnimatedAppBar extends StatelessWidget {
const AnimatedAppBar({
super.key,
required this.scrollController,
required this.textStyle,
required this.tabController,
});
final ScrollController scrollController;
final double collapsedHeight = 100;
final double expandedHeight = 300;
final double avatarSize = 50;
final TextStyle textStyle;
final TabController tabController;
String get headerText {
return switch (tabController.index) {
0 => 'Create a recipe',
1 => 'Saved recipes',
2 => 'Settings',
_ => 'Uh oh!',
};
}
String get helperText {
return switch (tabController.index) {
0 =>
"Tell me what ingredients you have and what you're feelin', and I'll create a recipe for you!",
1 => "These are all my saved recipes created by Chef Noodle.",
2 => 'Settings',
_ => 'Uh oh!',
};
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constraints) {
return SliverAppBar(
automaticallyImplyLeading: false,
pinned: true,
forceElevated: true,
elevation: 2,
shadowColor: Colors.black,
expandedHeight: expandedHeight,
collapsedHeight: collapsedHeight,
backgroundColor: Theme.of(context).primaryColor,
shape: const AppBarShapeBorder(50),
title: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: avatarSize,
height: avatarSize,
child: SvgPicture.asset(
'assets/chef_cat.svg',
semanticsLabel: 'Chef cat icon',
),
),
const SizedBox(
width: MarketplaceTheme.spacing1,
),
if (scrollController.positions.isNotEmpty &&
scrollController.offset < 200)
Text(
"Meowdy! Let's get cooking!",
style: MarketplaceTheme.heading3,
),
if (scrollController.positions.isNotEmpty &&
scrollController.offset > 200)
Text(
headerText,
style: MarketplaceTheme.heading3,
),
const Spacer(),
if (scrollController.positions.isNotEmpty &&
scrollController.offset > 200)
IconButton(
onPressed: () => showDialog<Null>(
context: context,
builder: (context) => const AppInfoDialog(),
),
icon: const Icon(
Symbols.info,
color: Colors.black12,
),
),
],
),
],
),
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing4),
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Text(
helperText,
style: constraints.isMobile
? MarketplaceTheme.subheading2
: MarketplaceTheme.subheading1,
),
),
IconButton(
onPressed: () {
showDialog<Null>(
context: context,
builder: (context) => const AppInfoDialog(),
);
},
icon: const Icon(
Symbols.info,
color: Colors.black12,
),
),
],
),
),
),
),
bottom: PreferredSize(
preferredSize: const Size(double.infinity, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
left: constraints.isMobile
? MarketplaceTheme.spacing2
: MarketplaceTheme.spacing1,
),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 0),
style: textStyle,
child: Text(
headerText,
),
),
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:image_picker/image_picker.dart';
import '../../util/filter_chip_enums.dart';
class PromptData {
PromptData({
required this.images,
required this.textInput,
Set<BasicIngredientsFilter>? basicIngredients,
Set<CuisineFilter>? cuisines,
Set<DietaryRestrictionsFilter>? dietaryRestrictions,
List<String>? additionalTextInputs,
}) : additionalTextInputs = additionalTextInputs ?? [],
selectedBasicIngredients = basicIngredients ?? {},
selectedCuisines = cuisines ?? {},
selectedDietaryRestrictions = dietaryRestrictions ?? {};
PromptData.empty()
: images = [],
additionalTextInputs = [],
selectedBasicIngredients = {},
selectedCuisines = {},
selectedDietaryRestrictions = {},
textInput = '';
String get cuisines {
return selectedCuisines.map((catFilter) => catFilter.name).join(",");
}
String get ingredients {
return selectedBasicIngredients
.map((ingredient) => ingredient.name)
.join(", ");
}
String get dietaryRestrictions {
return selectedDietaryRestrictions
.map((restriction) => restriction.name)
.join(", ");
}
List<XFile> images;
String textInput;
List<String> additionalTextInputs;
Set<BasicIngredientsFilter> selectedBasicIngredients;
Set<CuisineFilter> selectedCuisines;
Set<DietaryRestrictionsFilter> selectedDietaryRestrictions;
PromptData copyWith({
List<XFile>? images,
String? textInput,
List<String>? additionalTextInputs,
Set<BasicIngredientsFilter>? basicIngredients,
Set<CuisineFilter>? cuisineSelections,
Set<DietaryRestrictionsFilter>? dietaryRestrictions,
}) {
return PromptData(
images: images ?? this.images,
textInput: textInput ?? this.textInput,
additionalTextInputs: additionalTextInputs ?? this.additionalTextInputs,
basicIngredients: basicIngredients ?? selectedBasicIngredients,
cuisines: cuisineSelections ?? selectedCuisines,
dietaryRestrictions: dietaryRestrictions ?? selectedDietaryRestrictions,
);
}
}

View File

@@ -0,0 +1,388 @@
import 'package:ai_recipe_generation/features/prompt/prompt_view_model.dart';
import 'package:ai_recipe_generation/util/extensions.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import '../../theme.dart';
import '../../util/filter_chip_enums.dart';
import '../../widgets/filter_chip_selection_input.dart';
import '../../widgets/highlight_border_on_hover_widget.dart';
import '../../widgets/marketplace_button_widget.dart';
import '../recipes/widgets/recipe_fullscreen_dialog.dart';
import 'widgets/full_prompt_dialog_widget.dart';
import 'widgets/image_input_widget.dart';
const double kAvatarSize = 50;
const double collapsedHeight = 100;
const double expandedHeight = 300;
const double elementPadding = MarketplaceTheme.spacing7;
class PromptScreen extends StatelessWidget {
const PromptScreen({super.key, required this.canScroll});
final bool canScroll;
@override
Widget build(BuildContext context) {
final viewModel = context.watch<PromptViewModel>();
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: canScroll
? const BouncingScrollPhysics()
: const NeverScrollableScrollPhysics(),
child: Container(
padding: constraints.isMobile
? const EdgeInsets.only(
left: MarketplaceTheme.spacing7,
right: MarketplaceTheme.spacing7,
bottom: MarketplaceTheme.spacing7,
top: MarketplaceTheme.spacing7,
)
: const EdgeInsets.only(
left: MarketplaceTheme.spacing7,
right: MarketplaceTheme.spacing7,
bottom: MarketplaceTheme.spacing1,
top: MarketplaceTheme.spacing7,
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: MarketplaceTheme.borderColor),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(50),
bottomRight:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(elementPadding + 10),
child: Text(
'Create a recipe:',
style: MarketplaceTheme.dossierParagraph.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
Padding(
padding: const EdgeInsets.all(
elementPadding,
),
child: SizedBox(
height: constraints.isMobile ? 130 : 230,
child: AddImageToPromptWidget(
height: constraints.isMobile ? 100 : 200,
width: constraints.isMobile ? 100 : 200,
),
),
),
if (constraints.isMobile)
Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label: "I also have these staple ingredients: ",
child: FilterChipSelectionInput<BasicIngredientsFilter>(
onChipSelected: (selected) {
viewModel.addBasicIngredients(
selected as Set<BasicIngredientsFilter>);
},
allValues: BasicIngredientsFilter.values,
selectedValues:
viewModel.userPrompt.selectedBasicIngredients,
),
),
),
if (constraints.isMobile)
Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label: "I'm in the mood for: ",
child: FilterChipSelectionInput<CuisineFilter>(
onChipSelected: (selected) {
viewModel.addCategoryFilters(
selected as Set<CuisineFilter>);
},
allValues: CuisineFilter.values,
selectedValues: viewModel.userPrompt.selectedCuisines,
),
),
),
if (constraints.isMobile)
Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label: "I have the following dietary restrictions:",
child:
FilterChipSelectionInput<DietaryRestrictionsFilter>(
onChipSelected: (selected) {
viewModel.addDietaryRestrictionFilter(
selected as Set<DietaryRestrictionsFilter>);
},
allValues: DietaryRestrictionsFilter.values,
selectedValues:
viewModel.userPrompt.selectedDietaryRestrictions,
),
),
),
if (!constraints.isMobile)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label: "I'm in the mood for: ",
child: FilterChipSelectionInput<CuisineFilter>(
onChipSelected: (selected) {
viewModel.addCategoryFilters(
selected as Set<CuisineFilter>);
},
allValues: CuisineFilter.values,
selectedValues:
viewModel.userPrompt.selectedCuisines,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label: "I also have these staple ingredients: ",
child: FilterChipSelectionInput<
BasicIngredientsFilter>(
onChipSelected: (selected) {
viewModel.addBasicIngredients(
selected as Set<BasicIngredientsFilter>);
},
allValues: BasicIngredientsFilter.values,
selectedValues: viewModel
.userPrompt.selectedBasicIngredients,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(elementPadding),
child: _FilterChipSection(
label:
"I have the following dietary restrictions:",
child: FilterChipSelectionInput<
DietaryRestrictionsFilter>(
onChipSelected: (selected) {
viewModel.addDietaryRestrictionFilter(selected
as Set<DietaryRestrictionsFilter>);
},
allValues: DietaryRestrictionsFilter.values,
selectedValues: viewModel
.userPrompt.selectedDietaryRestrictions,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(elementPadding),
child: _TextField(
controller: viewModel.promptTextController,
onChanged: (value) {
viewModel.notify();
},
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!constraints.isMobile) const Spacer(flex: 1),
if (!constraints.isMobile)
Expanded(
flex: 3,
child: MarketplaceButton(
onPressed: viewModel.resetPrompt,
buttonText: 'Reset prompt',
icon: Symbols.restart_alt,
iconColor: Colors.black45,
buttonBackgroundColor: Colors.transparent,
hoverColor:
MarketplaceTheme.secondary.withOpacity(.1),
),
),
const Spacer(flex: 1),
Expanded(
flex: constraints.isMobile ? 10 : 3,
child: MarketplaceButton(
onPressed: () {
final promptData = viewModel.buildPrompt();
showDialog<Null>(
context: context,
builder: (context) {
return FullPromptDialog(
promptData: promptData,
);
},
);
},
buttonText: 'Full prompt',
icon: Symbols.info_rounded,
),
),
const Spacer(flex: 1),
Expanded(
flex: constraints.isMobile ? 10 : 3,
child: MarketplaceButton(
onPressed: () async {
await viewModel.submitPrompt().then((_) async {
if (viewModel.recipe != null) {
bool? shouldSave = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => RecipeDialogScreen(
recipe: viewModel.recipe!,
actions: [
MarketplaceButton(
onPressed: () {
Navigator.of(context).pop(true);
},
buttonText: "Save Recipe",
icon: Symbols.save,
),
],
),
);
if (shouldSave != null && shouldSave) {
viewModel.saveRecipe();
}
}
});
},
buttonText: 'Submit prompt',
icon: Symbols.send,
),
),
const Spacer(flex: 1),
],
),
),
if (constraints.isMobile)
Align(
alignment: Alignment.center,
child: MarketplaceButton(
onPressed: viewModel.resetPrompt,
buttonText: 'Reset prompt',
icon: Symbols.restart_alt,
iconColor: Colors.black45,
buttonBackgroundColor: Colors.transparent,
hoverColor: MarketplaceTheme.secondary.withOpacity(.1),
),
),
const SizedBox(height: 200.0),
],
),
),
),
);
},
);
}
}
class _FilterChipSection extends StatelessWidget {
const _FilterChipSection({
required this.child,
required this.label,
});
final Widget child;
final String label;
@override
Widget build(BuildContext context) {
return HighlightBorderOnHoverWidget(
borderRadius: BorderRadius.zero,
child: Container(
height: 230,
decoration: BoxDecoration(
color: Theme.of(context).splashColor.withOpacity(.1),
border: Border.all(
color: MarketplaceTheme.borderColor,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing7),
child: Text(
label,
style: MarketplaceTheme.dossierParagraph,
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: child,
),
],
),
),
);
}
}
class _TextField extends StatelessWidget {
const _TextField({
required this.controller,
this.onChanged,
});
final TextEditingController controller;
final Null Function(String)? onChanged;
@override
Widget build(BuildContext context) {
return TextField(
scrollPadding: const EdgeInsets.only(bottom: 150),
maxLines: null,
onChanged: onChanged,
minLines: 3,
controller: controller,
style: WidgetStateTextStyle.resolveWith(
(states) => MarketplaceTheme.dossierParagraph),
decoration: InputDecoration(
fillColor: Theme.of(context).splashColor,
hintText: "Add additional context...",
hintStyle: WidgetStateTextStyle.resolveWith(
(states) => MarketplaceTheme.dossierParagraph,
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(width: 1, color: Colors.black12),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(width: 1, color: Colors.black45),
),
filled: true,
),
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:ai_recipe_generation/services/gemini.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:image_picker/image_picker.dart';
import '../../services/firestore.dart';
import '../../util/filter_chip_enums.dart';
import '../recipes/recipe_model.dart';
import 'prompt_model.dart';
class PromptViewModel extends ChangeNotifier {
PromptViewModel({
required this.multiModalModel,
required this.textModel,
});
final GenerativeModel multiModalModel;
final GenerativeModel textModel;
bool loadingNewRecipe = false;
PromptData userPrompt = PromptData.empty();
TextEditingController promptTextController = TextEditingController();
String badImageFailure =
"The recipe request either does not contain images, or does not contain images of food items. I cannot recommend a recipe.";
Recipe? recipe;
String? _geminiFailureResponse;
String? get geminiFailureResponse => _geminiFailureResponse;
set geminiFailureResponse(String? value) {
_geminiFailureResponse = value;
notifyListeners();
}
void notify() => notifyListeners();
void addImage(XFile image) {
userPrompt.images.insert(0, image);
notifyListeners();
}
void addAdditionalPromptContext(String text) {
final existingInputs = userPrompt.additionalTextInputs;
userPrompt.copyWith(additionalTextInputs: [...existingInputs, text]);
}
void removeImage(XFile image) {
userPrompt.images.removeWhere((el) => el.path == image.path);
notifyListeners();
}
void resetPrompt() {
userPrompt = PromptData.empty();
notifyListeners();
}
// Creates an ephemeral prompt with additional text that the user shouldn't be
// concerned with to send to Gemini, such as formatting.
PromptData buildPrompt() {
return PromptData(
images: userPrompt.images,
textInput: mainPrompt,
basicIngredients: userPrompt.selectedBasicIngredients,
cuisines: userPrompt.selectedCuisines,
dietaryRestrictions: userPrompt.selectedDietaryRestrictions,
additionalTextInputs: [format],
);
}
Future<void> submitPrompt() async {
loadingNewRecipe = true;
notifyListeners();
// Create an ephemeral PromptData, preserving the user prompt data without
// adding the additional context to it.
var model = userPrompt.images.isEmpty ? textModel : multiModalModel;
final prompt = buildPrompt();
try {
final content = await GeminiService.generateContent(model, prompt);
// handle no image or image of not-food
if (content.text != null && content.text!.contains(badImageFailure)) {
geminiFailureResponse = badImageFailure;
} else {
recipe = Recipe.fromGeneratedContent(content);
}
} catch (error) {
geminiFailureResponse = 'Failed to reach Gemini. \n\n$error';
if (kDebugMode) {
print(error);
}
loadingNewRecipe = false;
}
loadingNewRecipe = false;
resetPrompt();
notifyListeners();
}
void saveRecipe() {
FirestoreService.saveRecipe(recipe!);
}
void addBasicIngredients(Set<BasicIngredientsFilter> ingredients) {
userPrompt.selectedBasicIngredients.addAll(ingredients);
notifyListeners();
}
void addCategoryFilters(Set<CuisineFilter> categories) {
userPrompt.selectedCuisines.addAll(categories);
notifyListeners();
}
void addDietaryRestrictionFilter(
Set<DietaryRestrictionsFilter> restrictions) {
userPrompt.selectedDietaryRestrictions.addAll(restrictions);
notifyListeners();
}
String get mainPrompt {
return '''
You are a Cat who's a chef that travels around the world a lot, and your travels inspire recipes.
Recommend a recipe for me based on the provided image.
The recipe should only contain real, edible ingredients.
If there are no images attached, or if the image does not contain food items, respond exactly with: $badImageFailure
Adhere to food safety and handling best practices like ensuring that poultry is fully cooked.
I'm in the mood for the following types of cuisine: ${userPrompt.cuisines},
I have the following dietary restrictions: ${userPrompt.dietaryRestrictions}
Optionally also include the following ingredients: ${userPrompt.ingredients}
Do not repeat any ingredients.
After providing the recipe, add an descriptions that creatively explains why the recipe is good based on only the ingredients used in the recipe. Tell a short story of a travel experience that inspired the recipe.
List out any ingredients that are potential allergens.
Provide a summary of how many people the recipe will serve and the the nutritional information per serving.
${promptTextController.text.isNotEmpty ? promptTextController.text : ''}
''';
}
final String format = '''
Return the recipe as valid JSON using the following structure:
{
"id": \$uniqueId,
"title": \$recipeTitle,
"ingredients": \$ingredients,
"description": \$description,
"instructions": \$instructions,
"cuisine": \$cuisineType,
"allergens": \$allergens,
"servings": \$servings,
"nutritionInformation": {
"calories": "\$calories",
"fat": "\$fat",
"carbohydrates": "\$carbohydrates",
"protein": "\$protein",
},
}
uniqueId should be unique and of type String.
title, description, cuisine, allergens, and servings should be of String type.
ingredients and instructions should be of type List<String>.
nutritionInformation should be of type Map<String, String>.
''';
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../theme.dart';
class AppInfoDialog extends StatelessWidget {
const AppInfoDialog({super.key});
Widget bulletRow(String text, {IconData? icon}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon ?? Symbols.label_important_outline),
const SizedBox(
width: 10,
),
Expanded(
child: Text(
text,
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
MarketplaceTheme.defaultBorderRadius,
),
),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: MarketplaceTheme.borderColor),
),
padding: const EdgeInsets.all(MarketplaceTheme.spacing4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Use the form on this screen to ask Cat Chef to make a recipe for you.",
style: MarketplaceTheme.heading3,
),
const SizedBox(
height: MarketplaceTheme.spacing4,
),
bulletRow(
"Add images of ingredients you have, like a picture of the inside of your fridge or pantry.",
icon: Symbols.looks_one,
),
const SizedBox(
height: MarketplaceTheme.spacing7,
),
bulletRow(
"Choose what kind of food you're in the mood for, and what staple ingredients you have that might not be pictured.",
icon: Symbols.looks_two,
),
const SizedBox(
height: MarketplaceTheme.spacing7,
),
bulletRow(
"In the text box at the bottom, add any additional context that you'd like. \nFor example, you could say \"I'm in a hurry! Make sure the recipe doesn't take longer than 30 minutes to make.\"",
icon: Symbols.looks_3,
),
const SizedBox(
height: MarketplaceTheme.spacing7,
),
bulletRow(
"Submit the prompt, and Chef Noodle will give you a recipe!",
icon: Symbols.looks_4,
),
const SizedBox(
height: MarketplaceTheme.spacing4,
),
Text(
"Steps 1, 2 and 3 are optional. More information will provide better results.",
style: MarketplaceTheme.label,
),
const SizedBox(height: MarketplaceTheme.spacing4),
TextButton.icon(
icon: const Icon(
Symbols.close,
color: Colors.black87,
),
label: Text(
'Close',
style: MarketplaceTheme.dossierParagraph,
),
onPressed: () {
Navigator.pop(context);
},
style: ButtonStyle(
shape: WidgetStateProperty.resolveWith(
(states) {
return const RoundedRectangleBorder(
side: BorderSide(color: Colors.black26),
borderRadius: BorderRadius.all(
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
);
},
),
textStyle: WidgetStateTextStyle.resolveWith(
(states) {
return MarketplaceTheme.dossierParagraph
.copyWith(color: Colors.black45);
},
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../theme.dart';
import '../../../widgets/prompt_image_widget.dart';
import '../prompt_model.dart';
class FullPromptDialog extends StatelessWidget {
const FullPromptDialog({super.key, required this.promptData});
final PromptData promptData;
Widget bulletRow(String text, {IconData? icon}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon ?? Symbols.label_important_outline),
const SizedBox(
width: 10,
),
Expanded(
child: Text(
text,
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
child: SingleChildScrollView(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: MarketplaceTheme.borderColor),
),
padding: const EdgeInsets.all(MarketplaceTheme.spacing4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"This is the full prompt that will be sent to Google's Gemini model.",
style: MarketplaceTheme.heading3,
),
const SizedBox(height: MarketplaceTheme.spacing4),
if (promptData.images.isNotEmpty)
Container(
height: 100,
decoration: const BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: MarketplaceTheme.borderColor,
),
),
),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var image in promptData.images)
Padding(
padding: const EdgeInsets.all(8.0),
child: PromptImage(
file: image,
),
),
],
),
),
const SizedBox(height: MarketplaceTheme.spacing4),
bulletRow(promptData.textInput),
if (promptData.additionalTextInputs.isNotEmpty)
...promptData.additionalTextInputs.map((i) => bulletRow(i)),
const SizedBox(height: MarketplaceTheme.spacing4),
TextButton.icon(
icon: const Icon(
Symbols.close,
color: Colors.black87,
),
label: Text(
'Close',
style: MarketplaceTheme.dossierParagraph,
),
onPressed: () {
Navigator.pop(context);
},
style: ButtonStyle(
shape: WidgetStateProperty.resolveWith(
(states) {
return const RoundedRectangleBorder(
side: BorderSide(color: Colors.black26),
borderRadius: BorderRadius.all(
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
);
},
),
textStyle: WidgetStateTextStyle.resolveWith(
(states) {
return MarketplaceTheme.dossierParagraph
.copyWith(color: Colors.black45);
},
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,278 @@
import 'package:ai_recipe_generation/widgets/highlight_border_on_hover_widget.dart';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../main.dart';
import '../../../theme.dart';
import '../../../util/device_info.dart';
import '../../../widgets/add_image_widget.dart';
import '../../../widgets/prompt_image_widget.dart';
import '../prompt_view_model.dart';
class AddImageToPromptWidget extends StatefulWidget {
const AddImageToPromptWidget({
super.key,
this.width = 100,
this.height = 100,
});
final double width;
final double height;
@override
State<AddImageToPromptWidget> createState() => _AddImageToPromptWidgetState();
}
class _AddImageToPromptWidgetState extends State<AddImageToPromptWidget> {
final ImagePicker picker = ImagePicker();
late CameraController _controller;
late Future<void> _initializeControllerFuture;
bool flashOn = false;
@override
void initState() {
super.initState();
if (DeviceInfo.isPhysicalDeviceWithCamera(deviceInfo)) {
_controller = CameraController(
camera,
ResolutionPreset.medium,
);
_initializeControllerFuture = _controller.initialize();
}
}
Future<XFile> _showCamera() async {
final image = await showGeneralDialog<XFile?>(
context: context,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return AnimatedOpacity(
opacity: animation.value,
duration: const Duration(milliseconds: 100),
child: child,
);
},
pageBuilder: (context, animation, secondaryAnimation) {
return Dialog.fullscreen(
insetAnimationDuration: const Duration(seconds: 1),
child: FutureBuilder(
future: _initializeControllerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
// If the Future is complete, display the preview.
return CameraView(
controller: _controller,
initializeControllerFuture: _initializeControllerFuture,
);
} else {
// Otherwise, display a loading indicator.
return const Center(child: CircularProgressIndicator());
}
},
),
);
},
);
if (image != null) {
return image;
} else {
throw "failed to take image";
}
}
Future<XFile> _pickImage() async {
final image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
return image;
} else {
throw "failed to take image";
}
}
Future<XFile> _addImage() async {
if (DeviceInfo.isPhysicalDeviceWithCamera(deviceInfo)) {
return await _showCamera();
} else {
return await _pickImage();
}
}
@override
Widget build(BuildContext context) {
final viewModel = context.watch<PromptViewModel>();
return HighlightBorderOnHoverWidget(
borderRadius: BorderRadius.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: MarketplaceTheme.spacing7,
top: MarketplaceTheme.spacing7,
),
child: Text(
'I have these ingredients:',
style: MarketplaceTheme.dossierParagraph,
),
),
SizedBox(
height: widget.height,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing7),
child: AddImage(
width: widget.width,
height: widget.height,
onTap: () async {
final image = await _addImage();
viewModel.addImage(image);
}),
),
for (var image in viewModel.userPrompt.images)
Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing7),
child: PromptImage(
width: widget.width,
file: image,
onTapIcon: () => viewModel.removeImage(image),
),
),
],
),
),
],
),
);
}
}
class CameraView extends StatefulWidget {
final CameraController controller;
final Future initializeControllerFuture;
const CameraView(
{super.key,
required this.controller,
required this.initializeControllerFuture});
@override
State<CameraView> createState() => _CameraViewState();
}
class _CameraViewState extends State<CameraView> {
bool flashOn = false;
@override
Widget build(BuildContext context) {
CameraController controller = widget.controller;
return Stack(
children: [
Center(
child: AspectRatio(
aspectRatio: 9 / 14,
child: ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
height: controller.value.previewSize!.width,
width: controller.value.previewSize!.height,
child: Center(
child: CameraPreview(
controller,
// child: ElevatedButton(
// child: Text('Button'),
// onPressed: () {},
// ),
),
),
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 89.5,
child: Container(
color: Colors.black.withOpacity(.7),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding:
const EdgeInsets.only(left: MarketplaceTheme.spacing4),
child: IconButton(
icon: Icon(
flashOn ? Symbols.flash_on : Symbols.flash_off,
size: 40,
color: flashOn ? Colors.yellowAccent : Colors.white,
),
onPressed: () {
controller.setFlashMode(
flashOn ? FlashMode.off : FlashMode.always);
setState(() {
flashOn = !flashOn;
});
},
),
),
Padding(
padding:
const EdgeInsets.only(right: MarketplaceTheme.spacing4),
child: IconButton(
icon: const Icon(
Symbols.cancel,
color: Colors.white,
size: 40,
),
onPressed: () async {
Navigator.of(context).pop();
},
),
),
],
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 150,
child: Container(
color: Colors.black.withOpacity(.7),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Symbols.camera,
color: Colors.white,
size: 70,
),
onPressed: () async {
try {
await widget.initializeControllerFuture;
final image = await controller.takePicture();
if (!context.mounted) return;
Navigator.of(context).pop(image);
} catch (e) {
rethrow;
}
},
),
],
),
),
)
],
);
}
}

View File

@@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:ai_recipe_generation/util/json_parsing.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
class Recipe {
Recipe({
required this.title,
required this.id,
required this.description,
required this.ingredients,
required this.instructions,
required this.cuisine,
required this.allergens,
required this.servings,
required this.nutritionInformation,
this.rating = -1,
});
final String id;
final String title;
final String description;
final List<String> ingredients;
final List<String> instructions;
final String cuisine;
final List<String> allergens;
final String servings;
final Map<String, dynamic> nutritionInformation;
int rating;
factory Recipe.fromGeneratedContent(GenerateContentResponse content) {
/// failures should be handled when the response is received
assert(content.text != null);
final validJson = cleanJson(content.text!);
final json = jsonDecode(validJson);
if (json
case {
"ingredients": List<dynamic> ingredients,
"instructions": List<dynamic> instructions,
"title": String title,
"id": String id,
"cuisine": String cuisine,
"description": String description,
"servings": String servings,
"nutritionInformation": Map<String, dynamic> nutritionInformation,
"allergens": List<dynamic> allergens,
}) {
return Recipe(
id: id,
title: title,
ingredients: ingredients.map((i) => i.toString()).toList(),
instructions: instructions.map((i) => i.toString()).toList(),
nutritionInformation: nutritionInformation,
allergens: allergens.map((i) => i.toString()).toList(),
cuisine: cuisine,
servings: servings,
description: description);
}
throw JsonUnsupportedObjectError(json);
}
Map<String, Object?> toFirestore() {
return {
'id': id,
'title': title,
'instructions': instructions,
'ingredients': ingredients,
'cuisine': cuisine,
'rating': rating,
'allergens': allergens,
'nutritionInformation': nutritionInformation,
'servings': servings,
'description': description,
};
}
factory Recipe.fromFirestore(Map<String, Object?> data) {
if (data
case {
"ingredients": List<dynamic> ingredients,
"instructions": List<dynamic> instructions,
"title": String title,
"id": String id,
"cuisine": String cuisine,
"description": String description,
"servings": String servings,
"nutritionInformation": Map<String, dynamic> nutritionInformation,
"allergens": List<dynamic> allergens,
"rating": int rating
}) {
return Recipe(
id: id,
title: title,
ingredients: ingredients.map((i) => i.toString()).toList(),
instructions: instructions.map((i) => i.toString()).toList(),
nutritionInformation: nutritionInformation,
allergens: allergens.map((i) => i.toString()).toList(),
cuisine: cuisine,
servings: servings,
description: description,
rating: rating,
);
}
throw "Malformed Firestore data";
}
}

View File

@@ -0,0 +1,31 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/cupertino.dart';
import '../../services/firestore.dart';
import 'recipe_model.dart';
class SavedRecipesViewModel extends ChangeNotifier {
List<Recipe> recipes = [];
final recipePath = '/recipes';
final firestore = FirebaseFirestore.instance;
SavedRecipesViewModel() {
firestore.collection(recipePath).snapshots().listen((querySnapshot) {
recipes = querySnapshot.docs.map((doc) {
final data = doc.data();
return Recipe.fromFirestore(data);
}).toList();
notifyListeners();
});
}
void deleteRecipe(Recipe recipe) {
FirestoreService.deleteRecipe(recipe);
}
void updateRecipe(Recipe recipe) {
FirestoreService.updateRecipe(recipe);
notifyListeners();
}
}

View File

@@ -0,0 +1,263 @@
import 'package:ai_recipe_generation/features/recipes/recipes_view_model.dart';
import 'package:ai_recipe_generation/features/recipes/widgets/recipe_fullscreen_dialog.dart';
import 'package:ai_recipe_generation/theme.dart';
import 'package:ai_recipe_generation/util/extensions.dart';
import 'package:ai_recipe_generation/widgets/highlight_border_on_hover_widget.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../widgets/marketplace_button_widget.dart';
import '../../widgets/star_rating.dart';
import 'recipe_model.dart';
class SavedRecipesScreen extends StatefulWidget {
const SavedRecipesScreen({super.key, required this.canScroll});
final bool canScroll;
@override
State<SavedRecipesScreen> createState() => _SavedRecipesScreenState();
}
class _SavedRecipesScreenState extends State<SavedRecipesScreen>
with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<SavedRecipesViewModel>();
return LayoutBuilder(
builder: (context, constraints) {
return Padding(
padding: constraints.isMobile
? const EdgeInsets.only(
left: MarketplaceTheme.spacing7,
right: MarketplaceTheme.spacing7,
bottom: MarketplaceTheme.spacing7,
top: MarketplaceTheme.spacing7,
)
: const EdgeInsets.only(
left: MarketplaceTheme.spacing7,
right: MarketplaceTheme.spacing7,
bottom: MarketplaceTheme.spacing1,
top: MarketplaceTheme.spacing7,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
topRight: Radius.circular(50),
bottomRight:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: MarketplaceTheme.borderColor),
borderRadius: const BorderRadius.only(
topLeft:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
topRight: Radius.circular(50),
bottomRight:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
color: Colors.white,
),
child: constraints.isMobile
? ListView.builder(
physics: widget.canScroll
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
itemCount: viewModel.recipes.length,
itemBuilder: (context, idx) {
final recipe = viewModel.recipes[idx];
return Container(
margin: EdgeInsets.only(top: idx == 0 ? 70 : 0),
child: Align(
heightFactor: .5,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: MarketplaceTheme.spacing7,
vertical: MarketplaceTheme.spacing7,
),
child: SizedBox(
width: MediaQuery.of(context).size.width * .99,
height: 200,
child: _ListTile(
constraints: constraints,
key: Key('$idx-${recipe.hashCode}'),
recipe: recipe,
idx: idx,
),
),
),
),
);
},
)
: GridView.count(
physics: widget.canScroll
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
childAspectRatio: 1.5,
children: [
...List.generate(viewModel.recipes.length, (idx) {
final recipe = viewModel.recipes[idx];
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: MarketplaceTheme.spacing7,
vertical: MarketplaceTheme.spacing7,
),
child: _ListTile(
key: Key('$idx-${recipe.hashCode}'),
recipe: recipe,
idx: idx,
constraints: constraints,
),
);
}),
],
),
),
),
);
},
);
}
}
class _ListTile extends StatefulWidget {
const _ListTile({
super.key,
required this.recipe,
this.idx = 0,
required this.constraints,
});
final Recipe recipe;
final int idx;
final BoxConstraints constraints;
@override
State<_ListTile> createState() => _ListTileState();
}
class _ListTileState extends State<_ListTile> {
final List<Color> colors = [
MarketplaceTheme.primary,
MarketplaceTheme.secondary,
MarketplaceTheme.tertiary,
MarketplaceTheme.scrim,
];
@override
Widget build(BuildContext context) {
final viewModel = context.watch<SavedRecipesViewModel>();
final color = colors[widget.idx % colors.length];
return GestureDetector(
child: HighlightBorderOnHoverWidget(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
topRight: Radius.circular(50),
bottomRight: Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
color: color,
child: Container(
decoration: const BoxDecoration(
boxShadow: [
BoxShadow(
offset: Offset(0, -2),
color: Colors.black38,
blurRadius: 5,
),
],
borderRadius: BorderRadius.only(
topLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
topRight: Radius.circular(50),
bottomRight:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
color: Colors.white,
),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(MarketplaceTheme.defaultBorderRadius),
topRight: Radius.circular(50),
bottomRight:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
bottomLeft:
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
color: color.withOpacity(.3),
),
padding: const EdgeInsets.all(MarketplaceTheme.spacing7),
child: Stack(
children: [
Text(
widget.recipe.title,
style: MarketplaceTheme.heading3,
),
Positioned(
top: widget.constraints.isMobile ? 40 : 60,
left: 0,
child: Text(
widget.recipe.cuisine,
style: MarketplaceTheme.subheading1,
),
),
Positioned(
right: 15,
top: widget.constraints.isMobile ? 40 : 60,
child: StartRating(
initialRating: widget.recipe.rating,
starColor: color,
onTap: null,
),
)
],
),
),
),
),
onTap: () async {
await showDialog<Null>(
context: context,
builder: (context) {
return RecipeDialogScreen(
recipe: widget.recipe,
subheading: Row(
children: [
const Text('My rating:'),
const SizedBox(width: 10),
StartRating(
initialRating: widget.recipe.rating,
starColor: MarketplaceTheme.tertiary,
onTap: (index) {
widget.recipe.rating = index + 1;
viewModel.updateRecipe(widget.recipe);
},
),
],
),
actions: [
MarketplaceButton(
onPressed: () {
viewModel.deleteRecipe(widget.recipe);
Navigator.of(context).pop();
},
buttonText: "Delete Recipe",
icon: Symbols.delete,
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,277 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../theme.dart';
import '../recipe_model.dart';
class RecipeDisplayWidget extends StatelessWidget {
const RecipeDisplayWidget({
super.key,
required this.recipe,
this.subheading,
});
final Recipe recipe;
final Widget? subheading;
List<Widget> _buildIngredients(List<String> ingredients) {
final widgets = <Widget>[];
for (var ingredient in ingredients) {
widgets.add(
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Symbols.stat_0_rounded,
size: 12,
),
const SizedBox(
width: 5,
),
Expanded(
child: Text(
ingredient,
softWrap: true,
),
),
],
),
);
}
return widgets;
}
List<Widget> _buildInstructions(List<String> instructions) {
final widgets = <Widget>[];
// check for existing numbers in instructions.
if (instructions.first.startsWith(RegExp('[0-9]'))) {
for (var instruction in instructions) {
widgets.add(Text(instruction));
widgets.add(const SizedBox(height: MarketplaceTheme.spacing6));
}
} else {
for (var i = 0; i < instructions.length; i++) {
widgets.add(Text(
'${i + 1}. ${instructions[i]}',
softWrap: true,
));
widgets.add(const SizedBox(height: MarketplaceTheme.spacing6));
}
}
return widgets;
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(MarketplaceTheme.defaultBorderRadius),
color: MarketplaceTheme.primary.withOpacity(.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
softWrap: true,
style: MarketplaceTheme.heading2,
),
if (subheading != null)
Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing7,
),
child: subheading,
),
],
),
),
TextButton(
style: ButtonStyle(
backgroundColor: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return MarketplaceTheme.scrim.withOpacity(.6);
}
return Colors.white;
}),
shape: WidgetStateProperty.resolveWith(
(states) {
return RoundedRectangleBorder(
side: const BorderSide(
color: MarketplaceTheme.primary),
borderRadius: BorderRadius.circular(
MarketplaceTheme.defaultBorderRadius,
),
);
},
),
textStyle: WidgetStateTextStyle.resolveWith(
(states) {
return MarketplaceTheme.dossierParagraph.copyWith(
color: Colors.black45,
);
},
),
),
onPressed: () async {
await showDialog<dynamic>(
context: context,
builder: (context) {
return AlertDialog(
content: Padding(
padding: const EdgeInsets.all(
MarketplaceTheme.spacing7),
child: Text(recipe.description),
),
);
},
);
},
child: Transform.translate(
offset: const Offset(0, 5),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing6),
child: Row(
children: [
SizedBox(
width: 35,
height: 35,
child: SvgPicture.asset(
'assets/chef_cat.svg',
semanticsLabel: 'Chef cat icon',
),
),
Transform.translate(
offset: const Offset(1, -6),
child: Transform.rotate(
angle: -pi / 20.0,
child: Text(
'Chef Noodle \n says...',
style: MarketplaceTheme.label,
),
),
)
],
),
),
),
)
],
),
const Divider(
height: 40,
color: Colors.black26,
),
Table(
columnWidths: const {
0: FlexColumnWidth(2),
1: FlexColumnWidth(3),
},
children: [
TableRow(
children: [
Text(
'Allergens:',
style: MarketplaceTheme.paragraph.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(recipe.allergens.join(', '))
],
),
TableRow(children: [
Text(
'Servings:',
style: MarketplaceTheme.paragraph.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(recipe.servings)
]),
TableRow(children: [
Text(
'Nutrition per serving:',
style: MarketplaceTheme.paragraph.copyWith(
fontWeight: FontWeight.bold,
),
),
const Text(''),
]),
...recipe.nutritionInformation.entries.map((entry) {
return TableRow(children: [
Row(
children: [
const Icon(
Symbols.stat_0_rounded,
size: 12,
),
const SizedBox(
width: 5,
),
Expanded(
child: Text(
entry.key,
style: MarketplaceTheme.label,
softWrap: true,
),
),
],
),
Text(entry.value as String,
style: MarketplaceTheme.label)
]);
}),
],
),
],
),
),
/// Body section
Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing4),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing7,
),
child:
Text('Ingredients:', style: MarketplaceTheme.subheading1),
),
..._buildIngredients(recipe.ingredients),
const SizedBox(height: MarketplaceTheme.spacing4),
Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing7),
child: Text('Instructions:',
style: MarketplaceTheme.subheading1),
),
..._buildInstructions(recipe.instructions),
],
),
)
],
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:ai_recipe_generation/features/recipes/widgets/recipe_display_widget.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../theme.dart';
import '../../../widgets/marketplace_button_widget.dart';
import '../recipe_model.dart';
class RecipeDialogScreen extends StatelessWidget {
const RecipeDialogScreen({
super.key,
required this.recipe,
required this.actions,
this.subheading,
});
final Recipe recipe;
final List<Widget> actions;
final Widget? subheading;
@override
Widget build(BuildContext context) {
return Dialog.fullscreen(
backgroundColor: Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: RecipeDisplayWidget(
recipe: recipe,
subheading: subheading,
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: MarketplaceTheme.spacing5,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
MarketplaceButton(
onPressed: () {
Navigator.of(context).pop(true);
},
buttonText: 'Close',
icon: Symbols.close,
),
...actions,
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,81 @@
// File generated by FlutterFire CLI.
// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'FIREBASE API KEY',
appId: 'FIREBASE APP ID',
messagingSenderId: 'FIREBASE MESSAGING ID',
projectId: 'PROJECT ID',
authDomain: 'AUTH DOMAIN',
storageBucket: 'STORAGE BUCKET ID',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'FIREBASE API KEY',
appId: 'FIREBASE APP ID',
messagingSenderId: 'FIREBASE MESSAGING ID',
projectId: 'PROJECT ID',
authDomain: 'AUTH DOMAIN',
storageBucket: 'STORAGE BUCKET ID',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'FIREBASE API KEY',
appId: 'FIREBASE APP ID',
messagingSenderId: 'FIREBASE MESSAGING ID',
projectId: 'PROJECT ID',
authDomain: 'AUTH DOMAIN',
storageBucket: 'STORAGE BUCKET ID',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'FIREBASE API KEY',
appId: 'FIREBASE APP ID',
messagingSenderId: 'FIREBASE MESSAGING ID',
projectId: 'PROJECT ID',
authDomain: 'AUTH DOMAIN',
storageBucket: 'STORAGE BUCKET ID',
);
}

View File

@@ -0,0 +1,122 @@
import 'package:ai_recipe_generation/util/device_info.dart';
import 'package:ai_recipe_generation/util/tap_recorder.dart';
import 'package:camera/camera.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:provider/provider.dart';
import 'features/prompt/prompt_view_model.dart';
import 'features/recipes/recipes_view_model.dart';
import 'firebase_options.dart';
import 'router.dart';
import 'theme.dart';
late CameraDescription camera;
late BaseDeviceInfo deviceInfo;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
deviceInfo = await DeviceInfo.initialize(DeviceInfoPlugin());
if (DeviceInfo.isPhysicalDeviceWithCamera(deviceInfo)) {
final cameras = await availableCameras();
camera = cameras.first;
}
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
late GenerativeModel geminiVisionProModel;
late GenerativeModel geminiProModel;
@override
void initState() {
const apiKey =
String.fromEnvironment('API_KEY', defaultValue: 'key not found');
if (apiKey == 'key not found') {
throw InvalidApiKey(
'Key not found in environment. Please add an API key.',
);
}
geminiVisionProModel = GenerativeModel(
model: 'gemini-pro-vision',
apiKey: apiKey,
generationConfig: GenerationConfig(
temperature: 0.4,
topK: 32,
topP: 1,
maxOutputTokens: 4096,
),
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.high),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.high),
],
);
geminiProModel = GenerativeModel(
model: 'gemini-pro',
apiKey: const String.fromEnvironment('API_KEY'),
generationConfig: GenerationConfig(
temperature: 0.4,
topK: 32,
topP: 1,
maxOutputTokens: 4096,
),
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.high),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.high),
],
);
super.initState();
}
@override
Widget build(BuildContext context) {
final recipesViewModel = SavedRecipesViewModel();
return TapRecorder(
child: MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => PromptViewModel(
multiModalModel: geminiVisionProModel,
textModel: geminiProModel,
),
),
ChangeNotifierProvider(
create: (_) => recipesViewModel,
),
],
child: SafeArea(
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: MarketplaceTheme.theme,
scrollBehavior: const ScrollBehavior().copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.unknown,
},
),
home: const AdaptiveRouter(),
),
),
),
);
}
}

View File

@@ -0,0 +1,248 @@
import 'package:ai_recipe_generation/app_bar.dart';
import 'package:ai_recipe_generation/features/prompt/prompt_screen.dart';
import 'package:ai_recipe_generation/features/prompt/prompt_view_model.dart';
import 'package:ai_recipe_generation/features/recipes/saved_recipes_screen.dart';
import 'package:ai_recipe_generation/widgets/bottom_bar_shape_border.dart';
import 'package:ai_recipe_generation/widgets/marketplace_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'theme.dart';
import 'widgets/icon_loading_indicator.dart';
const double avatarSize = 50;
const double collapsedHeight = 100;
const double expandedHeight = 300;
const double bottomTabBarHeight = 50;
class AdaptiveRouter extends StatefulWidget {
const AdaptiveRouter({super.key});
@override
State<AdaptiveRouter> createState() => _AdaptiveRouterState();
}
class _AdaptiveRouterState extends State<AdaptiveRouter>
with TickerProviderStateMixin {
late TextStyle _textStyle;
late ScrollController scrollController;
late TabController tabController;
bool innerScrollAllowed = false;
@override
void initState() {
super.initState();
tabController = TabController(length: 2, vsync: this);
_textStyle = MarketplaceTheme.heading1.copyWith(
color: Colors.black87.withOpacity(
1.0,
),
);
scrollController = ScrollController();
scrollController.addListener(_scrollListener);
}
double prevOffset = 0;
void _scrollListener() {
setState(() {
innerScrollAllowed = scrollController.offset >= 230;
if (scrollController.offset >= 230) {
scrollController.animateTo(230,
duration: const Duration(milliseconds: 100),
curve: Curves.decelerate);
}
// Don't change the text opacity if scrolling down from original position (overscroll)
if (scrollController.offset < 0) return;
// By offset 200, ensure the text is transparent
if (scrollController.offset > 200) {
_textStyle = _textStyle.copyWith(
color: Colors.black87.withOpacity(0),
);
return;
}
var value = double.parse(
(1 - (scrollController.offset - 50) / 100).toStringAsFixed(2),
);
if (scrollController.offset > 200 && value > 0) value = 0;
if (value > 1) value = 1;
if (value < 0) value = 0;
_textStyle = _textStyle.copyWith(
color: Colors.black87.withOpacity(
value,
),
);
});
}
@override
void dispose() {
scrollController.dispose();
tabController.dispose();
super.dispose();
}
List<NavigationRailDestination> destinations = [
const NavigationRailDestination(
icon: Icon(Symbols.home),
label: Text('Create a recipe'),
),
const NavigationRailDestination(
icon: Icon(Symbols.bookmarks),
label: Text('Saved Recipes'),
)
];
@override
Widget build(BuildContext context) {
final viewModel = context.watch<PromptViewModel>();
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
AnimatedAppBar(
scrollController: scrollController,
textStyle: _textStyle,
tabController: tabController,
),
SliverToBoxAdapter(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.minHeight,
),
child: TabBarView(
controller: tabController,
children: [
PromptScreen(
canScroll: innerScrollAllowed,
),
SavedRecipesScreen(
canScroll: innerScrollAllowed,
),
],
),
),
)
],
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: bottomTabBarHeight,
decoration: ShapeDecoration(
shadows: const [
BoxShadow(
offset: Offset(1, -1),
color: Colors.black45,
blurRadius: 5,
)
],
shape: const BottomBarShapeBorder(50),
color: Theme.of(context).primaryColor,
),
child: TabBar(
labelColor: Colors.black,
unselectedLabelColor: Colors.black26,
controller: tabController,
onTap: (idx) {
setState(() {});
},
dividerColor: Colors.transparent,
tabs: [
for (var destination in destinations) destination.icon,
],
),
),
),
if (viewModel.loadingNewRecipe)
Positioned(
top: (MediaQuery.of(context).size.height / 2) - 80,
left: (MediaQuery.of(context).size.width / 2) - 80,
height: 160,
width: 160,
child: IconLoadingAnimator(
icons: const [
Symbols.icecream,
Symbols.local_pizza,
Symbols.restaurant_menu,
Symbols.egg,
Symbols.bakery_dining,
Symbols.skillet,
Symbols.nutrition,
Symbols.grocery,
Symbols.set_meal,
Icons.egg_alt,
Symbols.oven,
Icons.dinner_dining,
Icons.outdoor_grill,
Icons.cookie,
Icons.blender,
Symbols.stockpot,
],
),
),
if (viewModel.geminiFailureResponse != null)
Positioned(
top: (MediaQuery.of(context).size.height / 4),
left: (MediaQuery.of(context).size.width / 2) - 160,
height: MediaQuery.of(context).size.height / 4,
width: 320,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
MarketplaceTheme.defaultBorderRadius),
boxShadow: const [
BoxShadow(
offset: Offset(-1, 1),
color: Colors.black45,
blurRadius: 5,
)
],
color: Colors.white,
border: Border.all(
color: MarketplaceTheme.focusedBorderColor,
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(MarketplaceTheme.spacing6),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(viewModel.geminiFailureResponse!),
Align(
alignment: Alignment.bottomRight,
child: MarketplaceButton(
onPressed: () {
viewModel.geminiFailureResponse = null;
},
buttonText: "Dismiss",
icon: Symbols.close,
),
)
],
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../features/recipes/recipe_model.dart';
const recipePath = '/recipes';
final firestore = FirebaseFirestore.instance;
class FirestoreService {
static Future<Null> saveRecipe(Recipe recipe) async {
await firestore
.collection(recipePath)
.doc(recipe.id)
.set(recipe.toFirestore());
}
static Future<Null> deleteRecipe(Recipe recipe) async {
await firestore.doc("$recipePath/${recipe.id}").delete();
}
static Future<Null> updateRecipe(Recipe recipe) async {
await firestore
.doc("$recipePath/${recipe.id}")
.update(recipe.toFirestore());
}
}

View File

@@ -0,0 +1,58 @@
import 'package:google_generative_ai/google_generative_ai.dart';
import '../features/prompt/prompt_model.dart';
class GeminiService {
static Future<GenerateContentResponse> generateContent(
GenerativeModel model, PromptData prompt) async {
if (prompt.images.isEmpty) {
return await GeminiService.generateContentFromText(model, prompt);
} else {
return await GeminiService.generateContentFromMultiModal(model, prompt);
}
}
static Future<GenerateContentResponse> generateContentFromMultiModal(
GenerativeModel model, PromptData prompt) async {
final mainText = TextPart(prompt.textInput);
final additionalTextParts =
prompt.additionalTextInputs.map((t) => TextPart(t));
final imagesParts = <DataPart>[];
for (var f in prompt.images) {
final bytes = await (f.readAsBytes());
imagesParts.add(DataPart('image/jpeg', bytes));
}
final input = [
Content.multi([...imagesParts, mainText, ...additionalTextParts])
];
return await model.generateContent(
input,
generationConfig: GenerationConfig(
temperature: 0.4,
topK: 32,
topP: 1,
maxOutputTokens: 4096,
),
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.high),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.high),
],
);
}
static Future<GenerateContentResponse> generateContentFromText(
GenerativeModel model, PromptData prompt) async {
final mainText = TextPart(prompt.textInput);
final additionalTextParts =
prompt.additionalTextInputs.map((t) => TextPart(t)).join("\n");
return await model.generateContent([
Content.text(
'${mainText.text} \n $additionalTextParts',
)
]);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
abstract class MarketplaceTheme {
static ThemeData theme = ThemeData(
fontFamily: GoogleFonts.lexend().fontFamily,
textTheme: GoogleFonts.lexendTextTheme().copyWith().apply(
bodyColor: const Color(0xff000000),
displayColor: const Color(0xff000000)),
colorScheme: const ColorScheme.light(
primary: Color(0xffA2E3F6),
secondary: Color(0xff4FAD85),
tertiary: Color(0xffDE7A60),
scrim: Color(0xffFFABC7),
surface: Color(0xffFDF7F0),
onSecondary: Color(0xff000000),
shadow: Color(0xffAEAEAE),
onPrimary: Color(0xffFFFFFF),
),
useMaterial3: true,
canvasColor: Colors.transparent,
navigationBarTheme: NavigationBarThemeData(
indicatorColor: const Color(0xffA2E3F6),
indicatorShape: CircleBorder(
side: BorderSide.lerp(
const BorderSide(
color: Color(0xff000000),
width: 2,
),
const BorderSide(
color: Color(0xff000000),
width: 2,
),
1),
),
),
);
static const Color primary = Color(0xffA2E3F6);
static const Color scrim = Color(0xffFFABC7);
static const Color tertiary = Color(0xffDE7A60);
static const Color secondary = Color(0xff4FAD85);
static const Color borderColor = Colors.black12;
static const Color focusedBorderColor = Colors.black45;
static const double defaultBorderRadius = 16;
static const double defaultTextSize = 16;
static const Color defaultTextColor = Colors.black87;
static TextStyle get heading1 => theme.textTheme.headlineLarge!.copyWith(
fontWeight: FontWeight.bold,
fontSize: 28,
//height: 36,
color: theme.colorScheme.onSecondary,
);
static TextStyle get heading2 => theme.textTheme.headlineMedium!.copyWith(
fontWeight: FontWeight.bold,
fontSize: 24,
//height: 32,
color: theme.colorScheme.onSecondary,
);
static TextStyle get heading3 => theme.textTheme.headlineSmall!.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
//height: 24,
color: theme.colorScheme.onSecondary,
);
static TextStyle get subheading1 => theme.textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.normal,
fontSize: 18,
//height: 20,
color: theme.colorScheme.onSecondary,
);
static TextStyle get subheading2 => theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.normal,
fontSize: 14,
//height: 18,
color: theme.colorScheme.onSecondary,
);
static TextStyle get paragraph => theme.textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.normal,
fontSize: 14,
//height: 18,
color: theme.colorScheme.onSecondary,
);
static TextStyle get label => theme.textTheme.labelSmall!.copyWith(
fontWeight: FontWeight.w600,
fontSize: 11,
//height: 16,
color: theme.colorScheme.onSecondary,
);
static TextStyle get dossierParagraph => GoogleFonts.anonymousPro().copyWith(
fontWeight: FontWeight.normal,
fontSize: 14,
//height: 18,
color: theme.colorScheme.onSecondary,
);
static TextStyle get dossierSubheading => GoogleFonts.anonymousPro().copyWith(
fontWeight: FontWeight.normal,
fontSize: 18,
//height: 18,
color: theme.colorScheme.onSecondary,
);
static TextStyle get dossierHeading => GoogleFonts.anonymousPro().copyWith(
fontWeight: FontWeight.bold,
fontSize: 28,
//height: 18,
color: theme.colorScheme.onSecondary,
);
static const double _spacingUnit = 8;
static const double spacing8 = _spacingUnit / 2;
static const double spacing7 = _spacingUnit;
static const double spacing6 = _spacingUnit * 1.5;
static const double spacing5 = _spacingUnit * 2;
static const double spacing4 = _spacingUnit * 2.5;
static const double spacing3 = _spacingUnit * 3;
static const double spacing2 = _spacingUnit * 3.5;
static const double spacing1 = _spacingUnit * 4;
static double lineWidth = 1;
static const Widget verticalSpacer = SizedBox(height: spacing5);
}

View File

@@ -0,0 +1,39 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
class DeviceInfo {
static Future<BaseDeviceInfo> initialize(DeviceInfoPlugin plugin) async {
if (kIsWeb) {
return await plugin.webBrowserInfo;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return await plugin.androidInfo;
case TargetPlatform.iOS:
return await plugin.iosInfo;
case TargetPlatform.macOS:
return plugin.macOsInfo;
case TargetPlatform.windows:
return await plugin.windowsInfo;
case TargetPlatform.linux:
return await plugin.linuxInfo;
default:
throw UnsupportedError(
'Device info not supported for this platform',
);
}
}
static bool isPhysicalDeviceWithCamera(BaseDeviceInfo deviceInfo) {
if (deviceInfo is! IosDeviceInfo && deviceInfo is! AndroidDeviceInfo) {
return false;
}
if (deviceInfo is IosDeviceInfo && deviceInfo.isPhysicalDevice) {
return true;
}
if (deviceInfo is AndroidDeviceInfo && deviceInfo.isPhysicalDevice) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/rendering.dart';
extension SliverBreakpointUtils on SliverConstraints {
bool get isTablet => crossAxisExtent > 730 && crossAxisExtent < 1000;
bool get isDesktop => crossAxisExtent > 1000;
bool get isMobile => crossAxisExtent < 730;
}
extension BoxBreakpointUtils on BoxConstraints {
bool get isTablet => maxWidth > 730 && maxWidth < 1000;
bool get isDesktop => maxWidth > 1000;
bool get isMobile => maxWidth < 730;
}

View File

@@ -0,0 +1,66 @@
enum CuisineFilter {
italian,
mexican,
american,
french,
japanese,
chinese,
indian,
greek,
moroccan,
ethiopian,
southAfrican,
}
enum BasicIngredientsFilter {
oil,
butter,
flour,
salt,
pepper,
sugar,
milk,
vinegar,
}
enum DietaryRestrictionsFilter {
vegan,
vegetarian,
lactoseIntolerant,
kosher,
// keto,
wheatAllergies,
nutAllergies,
fishAllergies,
soyAllergies,
}
String dietaryRestrictionReadable(DietaryRestrictionsFilter filter) {
return switch (filter) {
DietaryRestrictionsFilter.vegan => 'vegan',
DietaryRestrictionsFilter.vegetarian => 'vegetarian',
DietaryRestrictionsFilter.lactoseIntolerant => 'dairy free',
DietaryRestrictionsFilter.kosher => 'kosher',
// DietaryRestrictionsFilter.keto => 'low carb',
DietaryRestrictionsFilter.wheatAllergies => 'wheat allergy',
DietaryRestrictionsFilter.nutAllergies => 'nut allergy',
DietaryRestrictionsFilter.fishAllergies => 'fish allergy',
DietaryRestrictionsFilter.soyAllergies => 'soy allergy',
};
}
String cuisineReadable(CuisineFilter filter) {
return switch (filter) {
CuisineFilter.italian => 'Italian',
CuisineFilter.mexican => 'Mexican',
CuisineFilter.american => 'American',
CuisineFilter.french => 'French',
CuisineFilter.japanese => 'Japanese',
CuisineFilter.chinese => 'Chinese',
CuisineFilter.indian => 'Indian',
CuisineFilter.ethiopian => 'Ethiopian',
CuisineFilter.moroccan => 'Moroccan',
CuisineFilter.greek => 'Greek',
CuisineFilter.southAfrican => 'South African',
};
}

View File

@@ -0,0 +1,8 @@
String cleanJson(String maybeInvalidJson) {
if (maybeInvalidJson.contains('```')) {
final withoutLeading = maybeInvalidJson.split('```json').last;
final withoutTrailing = withoutLeading.split('```').first;
return withoutTrailing;
}
return maybeInvalidJson;
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
/// From: https://gist.github.com/creativecreatorormaybenot/cd42b60cb33c9962b19f629ec638d4de
/// This is code that I (https://twitter.com/creativemaybeno) wrote for a
/// StackOverflow answer.
/// You can find it here: https://stackoverflow.com/a/65067655/6509751.
/// List of the taps recorded by [TapRecorder].
///
/// This is only a make-shift solution of course. This will only be viable
/// when using a single [TapRecorder] because it is saved as a top-level
/// variable.
@visibleForTesting
final recordedTaps = <Offset>[];
/// These are the parameters for the visualization of the recorded taps.
const _tapRadius = 15.0,
_tapDuration = Duration(milliseconds: 420),
_tapColor = Colors.white,
_shadowColor = Colors.black,
_shadowElevation = 2.0;
/// Widget that records any taps that hit its child.
///
/// It does not matter to this widget whether the child accepts the hit events.
/// Everything hitting the rect of the child will be recorded.
///
/// It will both visualize them and add them to [recordedTaps].
class TapRecorder extends SingleChildRenderObjectWidget {
const TapRecorder({super.key, required Widget child}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderTapRecorder();
}
}
class _RenderTapRecorder extends RenderProxyBox with _SilentTickerProvider {
final _recordedTaps = <_RecordedTap>[];
@override
void detach() {
for (final recordedTap in _recordedTaps) {
(recordedTap.animation as AnimationController).dispose();
}
_recordedTaps.clear();
super.detach();
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false;
// We always want to add a hit test entry for ourselves as we want to react
// to each and every hit event.
result.add(BoxHitTestEntry(this, position));
return hitTestChildren(result, position: position);
}
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
// We do not want to interfere in the gesture arena, which is why we are not
// using regular tap recognizers. Instead, we handle it ourselves and always
// react to the hit events (ignoring the gesture arena).
if (event is PointerDownEvent) {
// Records the global position.
recordedTaps.add(event.position);
final controller = AnimationController(
vsync: this,
duration: _tapDuration,
),
recordedTap = _RecordedTap(event.localPosition, controller);
_recordedTaps.add(recordedTap);
controller
..addListener(markNeedsPaint)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.dispose();
_recordedTaps.remove(recordedTap);
}
})
..forward();
}
}
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
final canvas = context.canvas;
for (final tap in _recordedTaps) {
final path = Path()
..addOval(
Rect.fromCircle(center: tap.localPosition, radius: _tapRadius));
final opacity = 1 - tap.animation.value;
canvas.drawShadow(
path, _shadowColor.withOpacity(opacity), _shadowElevation, true);
canvas.drawPath(path, Paint()..color = _tapColor.withOpacity(opacity));
}
}
}
class _RecordedTap {
_RecordedTap(this.localPosition, this.animation);
final Offset localPosition;
final Animation<double> animation;
}
/// Ticker provider that does not perform any diagnostics.
///
/// We trust that the [_RenderTapRecorder] instance will dispose all tickers
/// by disposing the animation controllers.
mixin _SilentTickerProvider implements TickerProvider {
@override
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../theme.dart';
class AddImage extends StatefulWidget {
const AddImage({
super.key,
required this.onTap,
this.height = 100,
this.width = 100,
});
final VoidCallback onTap;
final double height;
final double width;
@override
State<AddImage> createState() => _AddImageState();
}
class _AddImageState extends State<AddImage> {
bool hovered = false;
bool tappedDown = false;
Color get buttonColor {
var state = (hovered, tappedDown);
return switch (state) {
// tapped down state
(_, true) => MarketplaceTheme.secondary.withOpacity(.7),
// hovered
(true, _) => MarketplaceTheme.secondary.withOpacity(.3),
// base color
(_, _) => MarketplaceTheme.secondary.withOpacity(.3),
};
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
hovered = true;
});
},
onExit: (event) {
setState(() {
hovered = false;
});
},
child: GestureDetector(
onTapDown: (details) {
setState(() {
tappedDown = true;
});
},
onTapUp: (details) {
setState(() {
tappedDown = false;
});
widget.onTap();
},
child: SizedBox(
width: widget.width,
height: widget.height,
child: ClipRRect(
borderRadius:
BorderRadius.circular(MarketplaceTheme.defaultBorderRadius),
child: Container(
decoration: BoxDecoration(
color: buttonColor,
),
child: const Center(
child: Icon(
Symbols.add_photo_alternate_rounded,
size: 32,
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class AppBarShapeBorder extends ShapeBorder {
final double radius;
const AppBarShapeBorder(this.radius);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return Path(); // Define inner path if needed
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
// Define your custom shape path here
Path path = Path();
path.moveTo(rect.left, rect.top);
path.lineTo(rect.left, rect.bottom - (radius * 2));
path.quadraticBezierTo(
rect.left,
rect.bottom - radius,
rect.left + radius,
rect.bottom - radius,
);
path.lineTo(rect.right - radius, rect.bottom - radius);
path.quadraticBezierTo(
rect.right, rect.bottom - radius, rect.right, rect.bottom);
path.lineTo(rect.right, rect.top);
path.close();
return path;
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
// Define your painting logic here
Paint paint = Paint()..color = Colors.transparent;
canvas.drawPath(getOuterPath(rect), paint);
}
@override
ShapeBorder scale(double t) {
// Implement scaling if needed
return this;
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class BottomBarShapeBorder extends ShapeBorder {
final double radius;
const BottomBarShapeBorder(this.radius);
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return Path(); // Define inner path if needed
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
// Define your custom shape path here
Path path = Path();
path.moveTo(rect.left, rect.top - radius);
path.quadraticBezierTo(
rect.left,
rect.top,
rect.left + radius,
rect.top,
);
path.lineTo(rect.right - radius, rect.top);
path.quadraticBezierTo(
rect.right,
rect.top,
rect.right,
rect.bottom,
);
path.lineTo(rect.left, rect.bottom);
path.lineTo(rect.left, rect.top + radius);
path.close();
return path;
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
// Define your painting logic here
Paint paint = Paint()..color = Colors.transparent;
canvas.drawPath(getOuterPath(rect), paint);
}
@override
ShapeBorder scale(double t) {
// Implement scaling if needed
return this;
}
}

View File

@@ -0,0 +1,41 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class CrossImage extends StatelessWidget {
const CrossImage({
super.key,
required this.file,
this.fit = BoxFit.cover,
this.height = 100,
this.width = 100,
});
final XFile file;
final BoxFit fit;
final double width;
final double height;
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return Image.network(
file.path,
fit: fit,
);
} else {
return Image.file(
File(file.path),
height: height,
width: width,
);
}
}
static DecorationImage decoration(XFile file, {BoxFit fit = BoxFit.cover}) {
final image = kIsWeb ? NetworkImage(file.path) : FileImage(File(file.path));
return DecorationImage(image: image as ImageProvider, fit: fit);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:ai_recipe_generation/theme.dart';
import 'package:ai_recipe_generation/util/extensions.dart';
import 'package:ai_recipe_generation/util/filter_chip_enums.dart';
import 'package:flutter/material.dart';
class FilterChipSelectionInput<T extends Enum> extends StatefulWidget {
const FilterChipSelectionInput({
super.key,
required this.onChipSelected,
required this.selectedValues,
required this.allValues,
});
final Null Function(Set) onChipSelected;
final Set<T> selectedValues;
final List<T> allValues;
@override
State<FilterChipSelectionInput> createState() =>
_CategorySelectionInputState<T>();
}
class _CategorySelectionInputState<T extends Enum>
extends State<FilterChipSelectionInput> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: Wrap(
spacing: 5.0,
runSpacing: constraints.isMobile ? 5.0 : -5.0,
children: List<Widget>.generate(
widget.allValues.length,
(idx) {
final chipData = widget.allValues[idx];
String label(dynamic chipData) {
if (chipData is CuisineFilter) {
return cuisineReadable(chipData);
} else if (chipData is DietaryRestrictionsFilter) {
return dietaryRestrictionReadable(chipData);
} else if (chipData is BasicIngredientsFilter) {
return chipData.name;
} else {
throw "unknown enum";
}
}
return FilterChip(
color: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return MarketplaceTheme.secondary.withOpacity(.5);
}
if (states.contains(WidgetState.selected)) {
return MarketplaceTheme.secondary.withOpacity(.3);
}
return Theme.of(context).splashColor;
}),
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
backgroundColor: Colors.transparent,
padding: const EdgeInsets.all(4),
label: Text(
label(chipData),
style: MarketplaceTheme.dossierParagraph,
),
selected: widget.selectedValues.contains(chipData),
onSelected: (selected) {
setState(
() {
if (selected) {
widget.selectedValues.add(chipData as T);
} else {
widget.selectedValues.remove(chipData);
}
widget.onChipSelected(widget.selectedValues);
},
);
},
);
},
).toList(),
),
);
});
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../theme.dart';
class HighlightBorderOnHoverWidget extends StatefulWidget {
const HighlightBorderOnHoverWidget({
super.key,
required this.child,
this.color = MarketplaceTheme.secondary,
required this.borderRadius,
});
final Widget child;
final Color color;
final BorderRadius borderRadius;
@override
State<HighlightBorderOnHoverWidget> createState() =>
_HighlightBorderOnHoverWidgetState();
}
class _HighlightBorderOnHoverWidgetState
extends State<HighlightBorderOnHoverWidget> {
bool hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
hovered = true;
});
},
onExit: (event) {
setState(() {
hovered = false;
});
},
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).splashColor.withOpacity(.1),
border: Border.all(
color: hovered ? widget.color : MarketplaceTheme.borderColor,
),
borderRadius: widget.borderRadius,
),
child: widget.child,
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:math';
import 'package:ai_recipe_generation/theme.dart';
import 'package:flutter/material.dart';
class IconLoadingAnimator extends StatefulWidget {
IconLoadingAnimator({
super.key,
required this.icons,
this.animationDuration,
this.millisecondsBetweenAnimations,
});
final List<IconData> icons;
final Duration? animationDuration;
final int? millisecondsBetweenAnimations;
final List<Color> colors = [
MarketplaceTheme.primary,
MarketplaceTheme.secondary,
MarketplaceTheme.tertiary,
MarketplaceTheme.scrim,
Colors.black87,
];
@override
State<IconLoadingAnimator> createState() => _IconLoadingAnimatorState();
}
var rand = Random();
class _IconLoadingAnimatorState extends State<IconLoadingAnimator> {
late List<IconData> notYetSeenIcons;
late IconData currentIcon;
late Color currentColor;
late Timer timer;
@override
void initState() {
super.initState();
notYetSeenIcons = widget.icons;
currentIcon =
notYetSeenIcons.removeAt(rand.nextInt(notYetSeenIcons.length));
currentColor = widget.colors[rand.nextInt(widget.colors.length)];
timer = Timer.periodic(
Duration(milliseconds: widget.millisecondsBetweenAnimations ?? 1000),
(timer) {
nextIcon();
},
);
}
void nextIcon() {
if (notYetSeenIcons.length == 1) notYetSeenIcons = widget.icons;
setState(() {
currentIcon =
notYetSeenIcons.removeAt(rand.nextInt(notYetSeenIcons.length));
currentColor = widget.colors[rand.nextInt(widget.colors.length)];
});
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: MarketplaceTheme.focusedBorderColor,
width: 2,
),
),
child: AnimatedSwitcher(
duration: widget.animationDuration ?? const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: Icon(
size: 75,
color: currentColor,
key: Key(currentIcon.hashCode.toString()),
currentIcon,
),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme.dart';
class MarketplaceButton extends StatefulWidget {
const MarketplaceButton({
super.key,
required this.onPressed,
required this.buttonText,
required this.icon,
this.iconRotateAngle,
this.iconBackgroundColor,
this.iconColor,
this.buttonBackgroundColor,
this.hoverColor,
});
final VoidCallback? onPressed;
final String buttonText;
final IconData icon;
final double? iconRotateAngle;
final Color? iconBackgroundColor;
final Color? iconColor;
final Color? buttonBackgroundColor;
final Color? hoverColor;
@override
State<MarketplaceButton> createState() => _MarketplaceButtonState();
}
class _MarketplaceButtonState extends State<MarketplaceButton> {
@override
Widget build(BuildContext context) {
return TextButton.icon(
icon: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.iconBackgroundColor ?? Colors.transparent,
),
child: Transform.rotate(
angle: widget.iconRotateAngle ?? 0,
child: Icon(
widget.icon,
color: widget.iconColor ?? Colors.black87,
size: 20.0,
),
),
),
label: Text(
widget.buttonText,
style: MarketplaceTheme.dossierParagraph,
),
onPressed: widget.onPressed,
style: ButtonStyle(
backgroundColor: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return widget.hoverColor ??
MarketplaceTheme.secondary.withOpacity(.3);
}
return widget.buttonBackgroundColor ??
Theme.of(context).splashColor.withOpacity(.3);
}),
shape: WidgetStateProperty.resolveWith(
(states) {
if (states.contains(WidgetState.hovered)) {
// TODO: how can I animate between states?
}
return const RoundedRectangleBorder(
side: BorderSide(color: Colors.black26),
borderRadius: BorderRadius.all(
Radius.circular(MarketplaceTheme.defaultBorderRadius),
),
);
},
),
textStyle: WidgetStateTextStyle.resolveWith(
(states) {
return MarketplaceTheme.dossierParagraph.copyWith(
color: Colors.black45,
);
},
),
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:ai_recipe_generation/theme.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'cross_image_widget.dart';
typedef OnTapRemoveImageCallback = void Function(XFile);
class PromptImage extends StatelessWidget {
const PromptImage({
super.key,
required this.file,
this.onTapIcon,
this.width = 100,
});
final XFile file;
final VoidCallback? onTapIcon;
final double width;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Stack(
children: [
Positioned(
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(
MarketplaceTheme.defaultBorderRadius,
),
),
child: Container(
foregroundDecoration: BoxDecoration(
image: CrossImage.decoration(file),
),
),
),
),
if (onTapIcon != null)
Positioned(
right: 5,
top: 5,
child: GestureDetector(
onTap: onTapIcon,
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(
Symbols.remove,
size: 16,
color: Colors.red.shade400,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
typedef StarRatingCallback = void Function(int);
class StartRating extends StatefulWidget {
const StartRating({
super.key,
required this.starColor,
required this.onTap,
this.initialRating = -1,
});
final Color starColor;
final int initialRating;
/// If [onTap] is not null, the stars are interactive
final StarRatingCallback? onTap;
@override
State<StartRating> createState() => _StartRatingState();
}
class _StartRatingState extends State<StartRating> {
late int selectedIdx;
@override
void initState() {
selectedIdx = widget.initialRating - 1;
super.initState();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
...List.generate(
5,
(index) => GestureDetector(
onTap: widget.onTap != null
? () {
setState(() {
selectedIdx = index;
});
widget.onTap!(index);
}
: null,
child: Icon(
Symbols.kid_star,
color: widget.starColor,
fill: selectedIdx >= index ? 1 : 0,
),
),
)
],
);
}
}