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
47 changes: 34 additions & 13 deletions lib/shared/widgets/step_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ class StepRow extends StatelessWidget {
final Widget? expanded;
final bool optional;

/// Draw the filled, outlined card around the row.
///
/// True suits `setup_unlock_sheet`, where each row IS a tappable action.
/// Set false for a pure PROGRESS list: there the boxes read as buttons
/// and invite a tap that does nothing.
final bool bordered;

/// Compact scale for nested rows (the generation passes under
/// "Generate site"): smaller badge and type, tighter padding.
final bool dense;

const StepRow({
super.key,
required this.state,
Expand All @@ -29,6 +40,8 @@ class StepRow extends StatelessWidget {
this.onCta,
this.expanded,
this.optional = false,
this.bordered = true,
this.dense = false,
});

@override
Expand All @@ -53,24 +66,29 @@ class StepRow extends StatelessWidget {
: theme.dividerColor.withValues(alpha: 0.4);

return Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor, width: isActive ? 1.5 : 1),
),
decoration: bordered
? BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: borderColor, width: isActive ? 1.5 : 1),
)
: null,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
padding: bordered
? const EdgeInsets.all(14)
: EdgeInsets.symmetric(vertical: dense ? 3 : 5),
child: Column(
children: [
Row(
children: [
_badge(isDone, isActive, isError, number, context),
const SizedBox(width: 12),
SizedBox(width: dense ? 8 : 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand All @@ -84,7 +102,7 @@ class StepRow extends StatelessWidget {
child: Text(
title,
style: TextStyle(
fontSize: 13,
fontSize: dense ? 12 : 13,
fontWeight: isActive || isError
? FontWeight.w600
: FontWeight.w500,
Expand Down Expand Up @@ -162,9 +180,10 @@ class StepRow extends StatelessWidget {
: done || active
? AppColors.primary
: Colors.transparent;
final size = dense ? 16.0 : 24.0;
return Container(
width: 24,
height: 24,
width: size,
height: size,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
Expand All @@ -177,16 +196,18 @@ class StepRow extends StatelessWidget {
),
alignment: Alignment.center,
child: error
? const Icon(LucideIcons.x, color: Colors.white, size: 14)
? Icon(LucideIcons.x,
color: Colors.white, size: dense ? 10 : 14)
: done
? const Icon(LucideIcons.check, color: Colors.white, size: 14)
? Icon(LucideIcons.check,
color: Colors.white, size: dense ? 10 : 14)
: Text(
n ?? '',
style: TextStyle(
color: active
? Colors.white
: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontSize: dense ? 9 : 12,
fontWeight: FontWeight.w600,
),
),
Expand Down
2 changes: 1 addition & 1 deletion lib/web/screens/web_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import 'package:fula_files/web/services/web_session.dart';

/// App version label shown in About + the home footer. Kept in one place
/// so the two stay in sync (the home footer imports this).
const String kWebAppVersion = 'v1.11.13.0';
const String kWebAppVersion = 'v1.11.14.0';

/// In-app web Settings page. Replaces the old behavior where the gear icon
/// opened cloud.fx.land in a new tab. Mirrors the mobile Settings screen's
Expand Down
45 changes: 36 additions & 9 deletions lib/web/screens/web_website_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1448,33 +1448,60 @@ class _StepChecklist extends StatelessWidget {
errorMessage: null,
uploadedAssets: g.uploadedAssets,
totalAssets: g.totalAssets,
subStep: service.subStepFor(g.id),
);

final rows = <Widget>[];
for (var i = 0; i < steps.length; i++) {
final step = steps[i];
final isUploadInFlight =
i == 0 && step.state == WebsiteStepState.active && g.totalAssets > 0;
if (i > 0) rows.add(const SizedBox(height: 6));

// Generation passes, indented under their parent step. Rendered in
// the `expanded` slot so they move with it.
final Widget? nested = step.subSteps.isEmpty
? null
: Padding(
padding: const EdgeInsets.only(left: 4, top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final sub in step.subSteps)
StepRow(
state: _rowState(sub.state),
title: sub.title,
bordered: false,
dense: true,
),
],
),
);

rows.add(StepRow(
state: switch (step.state) {
WebsiteStepState.pending => StepRowState.pending,
WebsiteStepState.active => StepRowState.active,
WebsiteStepState.done => StepRowState.done,
WebsiteStepState.failed => StepRowState.error,
},
state: _rowState(step.state),
number: '${i + 1}',
title: step.title,
subtitle: step.subtitle,
// Borderless: this is a progress list, not a menu. The boxed
// variant reads as tappable buttons and invites a tap that does
// nothing.
bordered: false,
expanded: isUploadInFlight
? LinearProgressIndicator(
value: g.uploadedAssets / g.totalAssets,
)
: null,
: nested,
));
}
return Column(children: rows);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: rows);
}

static StepRowState _rowState(WebsiteStepState s) => switch (s) {
WebsiteStepState.pending => StepRowState.pending,
WebsiteStepState.active => StepRowState.active,
WebsiteStepState.done => StepRowState.done,
WebsiteStepState.failed => StepRowState.error,
};
}

/// Click-tracking stats line for one generation — mirror of the native
Expand Down
83 changes: 83 additions & 0 deletions lib/web/services/web_generation_steps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,69 @@ const String kServerPhasePublishing = 'publishing';

enum WebsiteStepState { pending, active, done, failed }

/// The server's three generation passes, surfaced as sub-steps under
/// "Generate site".
///
/// These are REAL — `claudeService.ts` reports each pass through the job's
/// `statusMessage`:
///
/// 'Designing art direction...' -> [design]
/// 'Building your website...' -> [build]
/// 'Polishing design and motion...'-> [polish]
///
/// The legacy single-pass path reports 'Generating website...' instead and
/// maps to null, which correctly renders no sub-steps rather than
/// inventing three.
enum WebsiteSubStep { design, build, polish }

String websiteSubStepLabel(WebsiteSubStep s) => switch (s) {
WebsiteSubStep.design => 'Design',
WebsiteSubStep.build => 'Build',
WebsiteSubStep.polish => 'Polish',
};

/// Which pass a `statusMessage` describes, or null when it names none.
///
/// Matched on the leading VERB, not the whole sentence: the wording is the
/// server's to change, and 'Polishing design and motion' also contains the
/// word "design" — so a naive `contains('design')` would report the wrong
/// pass for the last one. The verbs do not overlap.
WebsiteSubStep? subStepFromStatusMessage(String? message) {
if (message == null) return null;
final m = message.toLowerCase();
if (m.contains('polish')) return WebsiteSubStep.polish;
if (m.contains('building')) return WebsiteSubStep.build;
if (m.contains('designing')) return WebsiteSubStep.design;
return null;
}

/// Fold a newly-observed pass into the one already held, never going
/// backwards — same rule as [advanceServerPhase]. An unrecognised message
/// (a status line that is not a pass marker) leaves the pass untouched
/// rather than clearing it.
WebsiteSubStep? advanceSubStep(WebsiteSubStep? previous, String? message) {
final incoming = subStepFromStatusMessage(message);
if (incoming == null) return previous;
if (previous == null) return incoming;
return incoming.index >= previous.index ? incoming : previous;
}

class WebsiteStep {
final String title;
final WebsiteStepState state;

/// Shown under the title. Only ever populated for the current step.
final String? subtitle;

/// Nested passes, currently only on "Generate site" and only once the
/// server has actually named one. Empty otherwise.
final List<WebsiteStep> subSteps;

const WebsiteStep({
required this.title,
required this.state,
this.subtitle,
this.subSteps = const [],
});

@override
Expand Down Expand Up @@ -112,6 +164,10 @@ List<WebsiteStep> buildWebsiteGenerationSteps({
String? errorMessage,
int uploadedAssets = 0,
int totalAssets = 0,

/// Furthest generation pass observed. Null until the server names one,
/// which is also the legacy single-pass case — then no sub-steps show.
WebsiteSubStep? subStep,
}) {
final failed = status == WebsiteGenStatus.error;
final completed = status == WebsiteGenStatus.completed;
Expand Down Expand Up @@ -151,7 +207,34 @@ List<WebsiteStep> buildWebsiteGenerationSteps({
title: kWebsiteStepTitles[i],
state: state,
subtitle: subtitle,
// Passes belong to "Generate site" (index 2) and only exist once
// the server has named one. A completed generation collapses them
// away — the detail is only interesting while it is running.
subSteps: (i == 2 && subStep != null && !completed)
? _passSteps(subStep, parentState: state)
: const [],
));
}
return steps;
}

/// The three passes, resolved against the furthest one reached.
List<WebsiteStep> _passSteps(
WebsiteSubStep reached, {
required WebsiteStepState parentState,
}) {
return [
for (final pass in WebsiteSubStep.values)
WebsiteStep(
title: websiteSubStepLabel(pass),
state: pass.index < reached.index
? WebsiteStepState.done
: pass.index == reached.index
// A failure inside the generate step failed THIS pass.
? (parentState == WebsiteStepState.failed
? WebsiteStepState.failed
: WebsiteStepState.active)
: WebsiteStepState.pending,
),
];
}
13 changes: 13 additions & 0 deletions lib/web/services/web_website_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,13 @@ class WebWebsiteService extends ChangeNotifier {
final Map<String, String> _serverPhase = {};
final Map<String, WebsiteGenStatus> _lastActiveStatus = {};

/// Furthest generation PASS observed, per generation. Derived from the
/// server's `statusMessage` ('Designing…' / 'Building…' / 'Polishing…')
/// and, like the phase above, monotonic and transient.
final Map<String, WebsiteSubStep> _subStep = {};

WebsiteSubStep? subStepFor(String generationId) => _subStep[generationId];

/// Directory opt-in for an IN-FLIGHT generation, keyed by generation
/// id. Transient for the same reason as the two maps above: it is only
/// needed between `startGeneration` and the `/generate` POST, after
Expand Down Expand Up @@ -480,6 +487,7 @@ class WebWebsiteService extends ChangeNotifier {
_serverPhase.remove(generationId);
_lastActiveStatus.remove(generationId);
_listInDirectory.remove(generationId);
_subStep.remove(generationId);
}

/// Turn a completed website's public-directory listing on or off.
Expand Down Expand Up @@ -1053,6 +1061,11 @@ class WebWebsiteService extends ChangeNotifier {
advanceServerPhase(_serverPhase[generation.id], serverStatus) !=
_serverPhase[generation.id];
_recordServerPhase(generation.id, serverStatus);
// The three generation passes are reported through statusMessage,
// so the sub-step is folded in from the same value.
final nextSub = advanceSubStep(_subStep[generation.id], statusMsg);
if (nextSub != null) _subStep[generation.id] = nextSub;

if (statusMsg != null) {
generation.statusMessage = statusMsg;
generation.updatedAt = DateTime.now();
Expand Down
Loading