Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions lib/web/screens/web_generate_website_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import 'package:fula_files/core/services/google_forms_service.dart';
import 'package:fula_files/web/services/web_website_service.dart';

/// Result returned when the user taps Publish — same shape as the
/// native GenerateWebsitePromptResult.
/// native GenerateWebsitePromptResult, plus [revisionRequest].
typedef WebGeneratePromptResult = ({
String websiteName,
String category,
Expand All @@ -23,6 +23,14 @@ typedef WebGeneratePromptResult = ({
String prompt,
bool enableTracking,
ContactFormConfig? contactForm,

/// What the user asked to CHANGE about an existing site. Empty for a
/// first-time build, and empty on a revision where the user only moved
/// the settings above (or nothing at all).
///
/// Deliberately separate from [prompt]: [prompt] stays the site's own
/// description, so the two can be compared to see what actually moved.
String revisionRequest,
});

class _PaletteOption {
Expand Down Expand Up @@ -153,6 +161,14 @@ class WebGenerateWebsiteScreen extends StatefulWidget {
final ContactFormConfig? initialContactForm;
final List<String>? initialLanguages;

/// Editing an existing site rather than building a new one.
///
/// Changes what the screen ASKS for: the fields below describe a site
/// that already exists and is already approved, so the question becomes
/// "what should change?" rather than "what should this be?". Leaving
/// everything alone is a valid answer and keeps the site as it is.
final bool revisionMode;

const WebGenerateWebsiteScreen({
super.key,
required this.defaultName,
Expand All @@ -165,6 +181,7 @@ class WebGenerateWebsiteScreen extends StatefulWidget {
this.initialEnableTracking = false,
this.initialContactForm,
this.initialLanguages,
this.revisionMode = false,
});

@override
Expand All @@ -179,6 +196,10 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
: widget.defaultName);
late final TextEditingController _promptController =
TextEditingController(text: widget.initialPrompt ?? '');

/// Revision mode only — what the user wants changed. Starts EMPTY: an
/// empty field means "change nothing", which is a real answer here.
final TextEditingController _revisionController = TextEditingController();
late String _category = _categoryOptions.contains(widget.initialCategory)
? widget.initialCategory!
: _categoryOptions.first;
Expand Down Expand Up @@ -244,6 +265,7 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
void dispose() {
_nameController.dispose();
_promptController.dispose();
_revisionController.dispose();
_destinationController.dispose();
_emailSubjectController.dispose();
_titleController.dispose();
Expand Down Expand Up @@ -306,6 +328,19 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
));
}

/// Everything about a contact form EXCEPT where it delivers to.
///
/// Two configs with the same spec describe the same form, so an existing
/// Google Form still serves it — only a spec change needs a new one.
static String _contactFormSpecOf(ContactFormConfig cfg) => ContactFormConfig(
enabled: cfg.enabled,
channel: cfg.channel,
destination: '',
emailSubject: cfg.emailSubject,
title: cfg.title,
fields: cfg.fields,
).encode();

Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
Expand All @@ -321,7 +356,30 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
return;
}

if (contactForm.channel == ContactFormChannel.sheets) {
// An UNCHANGED Sheets form on a revision keeps the form it already
// has. Creating a new one would abandon the responses collected so
// far, change the iframe URL in the page, and — because the
// responder URL is part of the stored prompt — make every edit look
// like a settings change, so "change nothing" could never be
// honoured on a site with a Google form.
final existing = widget.initialContactForm;
final formUnchanged = widget.revisionMode &&
existing != null &&
existing.channel == ContactFormChannel.sheets &&
existing.destination.trim().isNotEmpty &&
_contactFormSpecOf(existing) == _contactFormSpecOf(contactForm);
if (formUnchanged) {
contactForm = ContactFormConfig(
enabled: contactForm.enabled,
channel: contactForm.channel,
destination: existing.destination,
emailSubject: contactForm.emailSubject,
title: contactForm.title,
fields: contactForm.fields,
);
}

if (contactForm.channel == ContactFormChannel.sheets && !formUnchanged) {
bool isLoadingShown = false;
try {
final granted = await AuthService.instance.requestFormsScope();
Expand Down Expand Up @@ -390,6 +448,8 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
prompt: _promptController.text.trim(),
enableTracking: _enableTracking,
contactForm: contactForm.enabled ? contactForm : null,
revisionRequest:
widget.revisionMode ? _revisionController.text.trim() : '',
));
}
}
Expand Down Expand Up @@ -443,13 +503,48 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
final theme = Theme.of(context);
final nameEmpty = _nameController.text.trim().isEmpty;
return Scaffold(
appBar: AppBar(title: const Text('Generate Website')),
appBar: AppBar(
title: Text(
widget.revisionMode ? 'Update Website' : 'Generate Website')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Revision mode leads with the only question that matters:
// what should change. Everything below it already describes
// a site the user has and approved, so it is shown as
// adjustable settings rather than as a fresh brief.
if (widget.revisionMode) ...[
TextField(
controller: _revisionController,
maxLength: 8000,
minLines: 3,
maxLines: 6,
autofocus: true,
decoration: const InputDecoration(
labelText: 'What should change?',
hintText:
'e.g. "Change the headline to Aurora Design Studio" '
'or "Add a contact section under the gallery"',
helperText:
'Only what you describe here (and any setting you '
'change below) will be altered — the rest of the site '
'stays exactly as it is. Leave this empty to keep the '
'site unchanged.',
helperMaxLines: 4,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Text(
'The settings below are how this site was built. Change one '
'only if you want it applied to the existing site.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
],
TextField(
controller: _nameController,
maxLength: 60,
Expand Down Expand Up @@ -602,14 +697,21 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
minLines: 4,
maxLines: 6,
decoration: InputDecoration(
labelText: 'Your creative direction',
hintText:
'Add anything specific about content, layout, or theme '
'— leave blank to use only the category and styles '
'above.',
helperText: 'Category- and style-specific instructions plus '
'technical constraints (static site, IPFS hosting, '
'responsive design) are added automatically.',
labelText: widget.revisionMode
? 'Creative direction this site was built from'
: 'Your creative direction',
hintText: widget.revisionMode
? null
: 'Add anything specific about content, layout, or theme '
'— leave blank to use only the category and styles '
'above.',
helperText: widget.revisionMode
? 'Editing this rewrites the brief for the whole site. '
'For a targeted change, use the field at the top '
'instead.'
: 'Category- and style-specific instructions plus '
'technical constraints (static site, IPFS hosting, '
'responsive design) are added automatically.',
helperMaxLines: 3,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
Expand Down Expand Up @@ -644,7 +746,7 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
style:
FilledButton.styleFrom(backgroundColor: AppColors.primary),
icon: const Icon(LucideIcons.sparkles, size: 18),
label: const Text('Publish'),
label: Text(widget.revisionMode ? 'Apply changes' : 'Publish'),
),
],
),
Expand Down
76 changes: 60 additions & 16 deletions lib/web/screens/web_website_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,16 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
}

void _onServiceTick() {
if (mounted) setState(() {});
if (!mounted) return;
setState(() {});
// One-shot messages the service can't show itself — today: an edit
// that turned out to change nothing, which never becomes a job and
// so would otherwise happen silently.
final notice = WebWebsiteService.instance.takeNotice();
if (notice != null) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(notice)));
}
}

/// Fold completed upload jobs into the asset rows (byteless: name +
Expand Down Expand Up @@ -410,7 +419,12 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
List<WebPickedAsset> get _readyAssets =>
[for (final a in _assets) if (a.isCidBacked) a];

Future<void> _publishFromResult(WebGeneratePromptResult result) async {
/// [baseCid] is set when this is an EDIT of an existing build: the
/// server uses it to find that site's source and revise it in place.
Future<void> _publishFromResult(
WebGeneratePromptResult result, {
String? baseCid,
}) async {
final enrichedPrompt = composeEnrichedWebsitePrompt(
websiteName: result.websiteName,
category: result.category,
Expand All @@ -427,10 +441,15 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
picked: List.of(_readyAssets),
enableTracking: result.enableTracking,
listInDirectory: _listInDirectory,
baseCid: baseCid,
revisionRequest: result.revisionRequest,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Website generation started')),
SnackBar(
content: Text(baseCid != null
? 'Applying your changes...'
: 'Website generation started')),
);
}
}
Expand Down Expand Up @@ -512,11 +531,38 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
}
}

/// Native Recreate parity: reopen the generator prefilled from the
/// generation's parsed prompt, with the prior-site reference seeded
/// into the creative direction; the group's current assets are
/// reused on publish.
/// Recreate = EDIT this site, not design a new one.
///
/// The generator reopens prefilled from the generation's parsed prompt
/// and asks the one question that matters — what should change. The
/// site's own source is what the AI edits (the server holds it, keyed
/// by this build's CID), so anything the user does not ask about comes
/// back untouched. An empty change request keeps the site exactly as it
/// is; the server answers that one without running the model at all.
Future<void> _recreate(WebsiteGeneration gen) async {
final baseCid = gen.resultCid;
if (baseCid == null || baseCid.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'This build has no published copy to edit — create a new website instead')));
return;
}
// Asked before the user invests any effort, because the answer can
// only be acted on beforehand: the generate endpoint ignores an
// unknown field, so a server without this feature would accept the
// job, charge for it, and return a redesigned site.
final canRevise = await WebWebsiteService.instance.supportsRevision();
if (!mounted) return;
if (!canRevise) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text(
'Editing an existing website is not available on this server yet')));
return;
}
return _recreateFrom(gen, baseCid);
}

Future<void> _recreateFrom(WebsiteGeneration gen, String baseCid) async {
// Same public-content acknowledgement as Create Website (native
// parity), including the directory choice — a recreate produces a
// NEW generation, so it gets its own decision rather than silently
Expand All @@ -530,13 +576,10 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
if (accepted != true || !mounted) return;
_listInDirectory = listInDirectory.value;
final parsed = parseStoredWebsitePrompt(gen.prompt);
final priorUrl = gen.gatewayUrl ?? '';
final priorPromptForRef =
parsed.userBody.isNotEmpty ? parsed.userBody : gen.prompt.trim();
final seededPrompt =
'The website "$priorUrl" was created for prompt: "$priorPromptForRef"\n\n'
'[Describe what to change or add for the new version]';

// The creative direction is restored VERBATIM — no "the website X was
// created for prompt Y" preamble. That sentence used to be the only
// link back to the previous site, and reading it as a fresh brief is
// exactly why Recreate produced a different site every time.
final result =
await Navigator.of(context).push<WebGeneratePromptResult>(
MaterialPageRoute(
Expand All @@ -547,10 +590,11 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
initialCategory: parsed.category,
initialStyles: parsed.styles,
initialPalette: parsed.palette,
initialPrompt: seededPrompt,
initialPrompt: parsed.userBody,
initialEnableTracking: gen.trackingEnabled,
initialContactForm: parsed.contactForm,
initialLanguages: parsed.languages,
revisionMode: true,
),
),
);
Expand All @@ -561,7 +605,7 @@ class _WebWebsiteDetailScreenState extends State<WebWebsiteDetailScreen> {
'No reusable assets in this group — import files first')));
return;
}
await _publishFromResult(result);
await _publishFromResult(result, baseCid: baseCid);
}

@override
Expand Down
Loading
Loading