feat: guided-setup wizard — localized explained options + plain-language plan
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Reworks the Setup-Assistent toward the zero-learning-curve bar
(docs/architecture/guided-setup.md, phase 1.1):
- Every scenario/intent/target choice is now a localized option CARD
with a one-line plain-language explanation of what it configures
(DE+EN, Sie-form) — replacing the bare dropdowns whose labels were
English enum humanizations ('Regulated Production', 'This Laptop').
- Three explained steps with a 'Schritt n von 3' progress line
(stakes → task → environment); the two adaptive toggles move to the
last step in plain language (no 'air-gapped' jargon).
- The review step renders a localized PLAIN-LANGUAGE summary built
from 'chain init --answers --plan-json' (the structured SetupPlan) —
'Ch∆In richtet einen regulierten Betrieb ein: signierte Module
verlangt · … · geändert wird nur ~/.chain/config.yaml' — instead of
echoing the CLI's English prose. Warns when an existing config will
be overwritten. After apply: a plain 'Fertig' + next steps.
flutter analyze clean; widget tests for the step flow + German option
labels. Remaining per plan: clickable follow-up actions, signature
dead-end fix, placement/auto-open, and the LLM free-text path.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
0073d77a67
commit
140408d26a
7 changed files with 1133 additions and 146 deletions
|
|
@ -1,14 +1,15 @@
|
|||
// Guided-setup wizard. Collects scenario / intent / target (plus two
|
||||
// adaptive toggles), then calls `chain init --answers` to PREVIEW the
|
||||
// assembled plan and `--apply` to write the config. All the policy lives
|
||||
// in the Rust deterministic engine (chain_core::guided_setup) — this is
|
||||
// a thin UI over the CLI, so the logic never diverges.
|
||||
// adaptive toggles) across three explained steps, then previews the
|
||||
// assembled plan in localized PLAIN LANGUAGE and applies it. All the
|
||||
// policy lives in the Rust deterministic engine
|
||||
// (chain_core::guided_setup); this is a thin UI over `chain init`.
|
||||
//
|
||||
// NOTE: the dropdown option labels are humanised from the canonical
|
||||
// kebab-case enum values (trying-out → "Trying Out"); localising those
|
||||
// per option is a follow-up. The structural UI + the applied plan
|
||||
// (rendered from the CLI's localisable output) are the meaningful parts.
|
||||
// The preview is rendered from `chain init --answers … --plan-json`
|
||||
// (a structured SetupPlan), so the operator sees a German/English
|
||||
// plain-language summary — never the CLI's English prose. Every
|
||||
// option carries a one-line explanation of what it configures.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -16,58 +17,66 @@ import 'package:flutter/material.dart';
|
|||
import '../data/error_presentation.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// A selectable setup option: the stable kebab wire value plus the
|
||||
/// localized label + one-line explanation resolved at build time.
|
||||
class _Option {
|
||||
final String value;
|
||||
final String Function(AppLocalizations) label;
|
||||
final String Function(AppLocalizations) sub;
|
||||
const _Option(this.value, this.label, this.sub);
|
||||
}
|
||||
|
||||
class GuidedSetupDialog extends StatefulWidget {
|
||||
const GuidedSetupDialog({super.key});
|
||||
|
||||
/// Launch the wizard as a modal dialog.
|
||||
static Future<void> show(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const GuidedSetupDialog(),
|
||||
);
|
||||
context: context,
|
||||
builder: (_) => const GuidedSetupDialog(),
|
||||
);
|
||||
|
||||
@override
|
||||
State<GuidedSetupDialog> createState() => _GuidedSetupDialogState();
|
||||
}
|
||||
|
||||
class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||
// Canonical SetupAnswers enum values (serde kebab-case in Rust).
|
||||
static const _scenarios = <String>[
|
||||
'trying-out',
|
||||
'team-hub',
|
||||
'regulated-production',
|
||||
'building-modules',
|
||||
// Canonical SetupAnswers wire values (serde kebab-case in Rust) with
|
||||
// their localized label + explanation.
|
||||
static final _scenarios = <_Option>[
|
||||
_Option('trying-out', (l) => l.setupScenTryingOut, (l) => l.setupScenTryingOutSub),
|
||||
_Option('team-hub', (l) => l.setupScenTeamHub, (l) => l.setupScenTeamHubSub),
|
||||
_Option('regulated-production', (l) => l.setupScenRegulated, (l) => l.setupScenRegulatedSub),
|
||||
_Option('building-modules', (l) => l.setupScenBuildModules, (l) => l.setupScenBuildModulesSub),
|
||||
];
|
||||
static const _intents = <String>[
|
||||
'hello-world',
|
||||
'extract-text',
|
||||
'extract-summarize',
|
||||
'classify-documents',
|
||||
'build-module',
|
||||
static final _intents = <_Option>[
|
||||
_Option('hello-world', (l) => l.setupIntentHello, (l) => l.setupIntentHelloSub),
|
||||
_Option('extract-text', (l) => l.setupIntentExtract, (l) => l.setupIntentExtractSub),
|
||||
_Option('extract-summarize', (l) => l.setupIntentExtractSummarize, (l) => l.setupIntentExtractSummarizeSub),
|
||||
_Option('classify-documents', (l) => l.setupIntentClassify, (l) => l.setupIntentClassifySub),
|
||||
_Option('build-module', (l) => l.setupIntentBuildModule, (l) => l.setupIntentBuildModuleSub),
|
||||
];
|
||||
static const _targets = <String>[
|
||||
'this-laptop',
|
||||
'home-server',
|
||||
'air-gapped-server',
|
||||
'container',
|
||||
static final _targets = <_Option>[
|
||||
_Option('this-laptop', (l) => l.setupTargetLaptop, (l) => l.setupTargetLaptopSub),
|
||||
_Option('home-server', (l) => l.setupTargetHomeServer, (l) => l.setupTargetHomeServerSub),
|
||||
_Option('air-gapped-server', (l) => l.setupTargetAirgapped, (l) => l.setupTargetAirgappedSub),
|
||||
_Option('container', (l) => l.setupTargetContainer, (l) => l.setupTargetContainerSub),
|
||||
];
|
||||
|
||||
int _step = 0; // 0 = answers, 1 = review/apply
|
||||
static const _totalSteps = 3; // three answer steps; the review is step 4
|
||||
|
||||
int _step = 0; // 0..2 answers, 3 = review/apply
|
||||
String _scenario = 'trying-out';
|
||||
String _intent = 'extract-summarize';
|
||||
String _target = 'this-laptop';
|
||||
bool _requireApproval = false;
|
||||
bool _dataLocal = false;
|
||||
bool _busy = false;
|
||||
String? _preview;
|
||||
String? _applied;
|
||||
Map<String, dynamic>? _plan; // parsed SetupPlan from --plan-json
|
||||
bool _applied = false;
|
||||
|
||||
String _humanize(String kebab) => kebab
|
||||
.split('-')
|
||||
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
||||
.join(' ');
|
||||
|
||||
String _answersYaml() => 'scenario: $_scenario\n'
|
||||
String _answersYaml() =>
|
||||
'scenario: $_scenario\n'
|
||||
'intent: $_intent\n'
|
||||
'target: $_target\n'
|
||||
'require_approval: $_requireApproval\n'
|
||||
|
|
@ -79,22 +88,36 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
return f.path;
|
||||
}
|
||||
|
||||
/// True when a config file already exists (so applying overwrites it).
|
||||
bool _configExists() {
|
||||
final home =
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null) return false;
|
||||
final sep = Platform.pathSeparator;
|
||||
return File('$home$sep.chain${sep}config.yaml').existsSync();
|
||||
}
|
||||
|
||||
Future<void> _goReview() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_preview = null;
|
||||
_plan = null;
|
||||
});
|
||||
final path = await _writeAnswers();
|
||||
final r = await SystemActions.chainInit(['--answers', path]);
|
||||
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (r.ok) {
|
||||
setState(() {
|
||||
_preview = r.stdout.trim();
|
||||
_step = 1;
|
||||
});
|
||||
try {
|
||||
final plan = jsonDecode(r.stdout.trim()) as Map<String, dynamic>;
|
||||
setState(() {
|
||||
_plan = plan;
|
||||
_step = _totalSteps; // review
|
||||
});
|
||||
} catch (_) {
|
||||
showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr);
|
||||
}
|
||||
} else {
|
||||
showFaiProcessError(context, 'chain init --answers', r.stdout, r.stderr);
|
||||
showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +130,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
if (r.ok) {
|
||||
setState(() => _applied = r.stdout.trim());
|
||||
setState(() => _applied = true);
|
||||
} else {
|
||||
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
|
||||
}
|
||||
|
|
@ -116,106 +139,167 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final reviewing = _step >= _totalSteps;
|
||||
return AlertDialog(
|
||||
title: Text(l.guidedSetupTitle),
|
||||
title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)),
|
||||
content: SizedBox(
|
||||
width: 460,
|
||||
width: 480,
|
||||
child: SingleChildScrollView(
|
||||
child: _step == 0 ? _answersStep(l) : _reviewStep(l),
|
||||
child: reviewing ? _reviewStep(l) : _answerStep(l),
|
||||
),
|
||||
),
|
||||
actions: _actions(l),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _answersStep(AppLocalizations l) {
|
||||
String _stepTitle(AppLocalizations l) => switch (_step) {
|
||||
0 => l.setupStep1Title,
|
||||
1 => l.setupStep2Title,
|
||||
_ => l.setupStep3Title,
|
||||
};
|
||||
|
||||
Widget _answerStep(AppLocalizations l) {
|
||||
final (options, selected, onSelect) = switch (_step) {
|
||||
0 => (_scenarios, _scenario, (String v) => setState(() => _scenario = v)),
|
||||
1 => (_intents, _intent, (String v) => setState(() => _intent = v)),
|
||||
_ => (_targets, _target, (String v) => setState(() => _target = v)),
|
||||
};
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.guidedSetupIntro),
|
||||
const SizedBox(height: 16),
|
||||
_dropdown(l.guidedSetupScenario, _scenarios, _scenario,
|
||||
(v) => setState(() => _scenario = v)),
|
||||
const SizedBox(height: 12),
|
||||
_dropdown(l.guidedSetupIntent, _intents, _intent,
|
||||
(v) => setState(() => _intent = v)),
|
||||
const SizedBox(height: 12),
|
||||
_dropdown(l.guidedSetupTarget, _targets, _target,
|
||||
(v) => setState(() => _target = v)),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.guidedSetupApproval),
|
||||
value: _requireApproval,
|
||||
onChanged: (v) => setState(() => _requireApproval = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.guidedSetupDataLocal),
|
||||
value: _dataLocal,
|
||||
onChanged: (v) => setState(() => _dataLocal = v),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: ChainSpace.sm),
|
||||
child: Text(
|
||||
l.setupStepOf(_step + 1, _totalSteps),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final o in options)
|
||||
_OptionCard(
|
||||
title: o.label(l),
|
||||
subtitle: o.sub(l),
|
||||
selected: selected == o.value,
|
||||
onTap: () => onSelect(o.value),
|
||||
),
|
||||
// The two adaptive toggles live on the last answer step, in
|
||||
// plain language (no "air-gapped" jargon).
|
||||
if (_step == 2) ...[
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.setupApprovalPlain),
|
||||
value: _requireApproval,
|
||||
onChanged: (v) => setState(() => _requireApproval = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l.setupDataLocalPlain),
|
||||
value: _dataLocal,
|
||||
onChanged: (v) => setState(() => _dataLocal = v),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdown(
|
||||
String label,
|
||||
List<String> items,
|
||||
String value,
|
||||
ValueChanged<String> onChanged,
|
||||
) {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: value,
|
||||
items: [
|
||||
for (final i in items)
|
||||
DropdownMenuItem<String>(value: i, child: Text(_humanize(i))),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _reviewStep(AppLocalizations l) {
|
||||
final applied = _applied;
|
||||
final plan = _plan;
|
||||
if (plan == null) return const SizedBox.shrink();
|
||||
final theme = Theme.of(context);
|
||||
final lines = _planLines(l, plan);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(applied != null
|
||||
? l.guidedSetupApplied
|
||||
: l.guidedSetupReviewHeading),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
if (_applied) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: ChainColors.success, size: 20),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(l.setupApplied, style: theme.textTheme.titleSmall),
|
||||
],
|
||||
),
|
||||
// SelectableText keeps the plan copyable (project rule:
|
||||
// anything an operator might want to keep is selectable).
|
||||
child: SelectableText(
|
||||
applied ?? _preview ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Text(l.setupNextTitle, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
_next(l.setupNextHubStart),
|
||||
if ((plan['modules'] as List?)?.isNotEmpty ?? false)
|
||||
_next(l.setupNextInstall),
|
||||
_next(l.setupNextRunFlow),
|
||||
] else ...[
|
||||
for (final line in lines)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('· ', style: theme.textTheme.bodyMedium),
|
||||
Expanded(
|
||||
child: Text(line, style: theme.textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_configExists()) ...[
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: theme.colorScheme.tertiary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l.setupPlanOverwriteWarn,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _next(String label) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Build the localized plain-language plan lines from the structured
|
||||
/// SetupPlan (never the CLI prose).
|
||||
List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
|
||||
final lines = <String>[];
|
||||
switch (plan['profile'] as String? ?? '') {
|
||||
case 'air-gapped':
|
||||
lines.add(l.setupPlanIntroAirgapped);
|
||||
case 'enterprise':
|
||||
lines.add(l.setupPlanIntroEnterprise);
|
||||
default:
|
||||
lines.add(l.setupPlanIntroDev);
|
||||
}
|
||||
if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures);
|
||||
if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm);
|
||||
if (plan['approval_step'] == true) lines.add(l.setupPlanApproval);
|
||||
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
|
||||
if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', ')));
|
||||
final flow = plan['starter_flow'] as String? ?? '';
|
||||
if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow));
|
||||
lines.add(l.setupPlanFileChanged('~/.chain/config.yaml'));
|
||||
return lines;
|
||||
}
|
||||
|
||||
List<Widget> _actions(AppLocalizations l) {
|
||||
if (_busy) {
|
||||
return const [
|
||||
|
|
@ -229,7 +313,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
),
|
||||
];
|
||||
}
|
||||
if (_applied != null) {
|
||||
if (_applied) {
|
||||
return [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
|
|
@ -237,21 +321,111 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
|||
),
|
||||
];
|
||||
}
|
||||
if (_step == 0) {
|
||||
if (_step < _totalSteps) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l.guidedSetupCancel),
|
||||
onPressed: _step == 0
|
||||
? () => Navigator.of(context).pop()
|
||||
: () => setState(() => _step -= 1),
|
||||
child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _step < _totalSteps - 1
|
||||
? () => setState(() => _step += 1)
|
||||
: _goReview,
|
||||
child: Text(l.guidedSetupNext),
|
||||
),
|
||||
FilledButton(onPressed: _goReview, child: Text(l.guidedSetupNext)),
|
||||
];
|
||||
}
|
||||
// Review step.
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 0),
|
||||
onPressed: () => setState(() => _step = _totalSteps - 1),
|
||||
child: Text(l.guidedSetupBack),
|
||||
),
|
||||
FilledButton(onPressed: _apply, child: Text(l.guidedSetupApply)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A selectable card: title + one-line explanation, highlighted when
|
||||
/// selected. Replaces the bare dropdowns so every choice explains
|
||||
/// itself in place (zero-learning-curve).
|
||||
class _OptionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _OptionCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final accent = theme.colorScheme.primary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: ChainSpace.sm),
|
||||
child: Material(
|
||||
color: selected
|
||||
? accent.withValues(alpha: 0.10)
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.md),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(ChainRadius.md),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: ChainSpace.md,
|
||||
vertical: ChainSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(ChainRadius.md),
|
||||
border: Border.all(
|
||||
color: selected ? accent : theme.colorScheme.outlineVariant,
|
||||
width: selected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: selected ? accent : theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue