1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 06:48:26 +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,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;
}
},
),
],
),
),
)
],
);
}
}