mirror of
https://github.com/flutter/samples.git
synced 2026-05-09 08:27:19 +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:
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user