feat(studio): show a source module's data terms before the install button
Some checks failed
Security / Security check (push) Failing after 2s

The store detail sheet now carries a data-source block for source.*
modules: publisher, upstream url, the terms in plain words, and any
attribution the operator has to carry with the output. It sits above
maintainers and above the install button, because it is a decision
input rather than a footnote.

The values are selectable: compliance notes get written by copying,
not retyping. A note names whose terms these are, so nobody reads them
as the module's own licence. Four guards, including that an empty
attribution renders no empty row.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-09-09 12:36:45 +02:00
parent fb809a4cbd
commit 35a79d0bb6
8 changed files with 307 additions and 44 deletions

View file

@ -253,8 +253,9 @@ class _StorePageState extends State<StorePage> {
icon: const Icon(Icons.add_business_outlined, size: 16),
label: Text(l.storesManagerButton),
onPressed: () => ChainStoresDialog.show(context),
style:
OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
IconButton(
@ -365,8 +366,7 @@ class _StorePageState extends State<StorePage> {
// Modules vs Studio plugins/themes a theme
// extends the GUI, a module runs in a flow.
Padding(
padding:
const EdgeInsets.only(bottom: ChainSpace.md),
padding: const EdgeInsets.only(bottom: ChainSpace.md),
child: ChainSegments<bool>(
items: [
ChainSegmentItem(
@ -381,8 +381,7 @@ class _StorePageState extends State<StorePage> {
),
],
value: _showStudio,
onChanged: (v) =>
setState(() => _showStudio = v),
onChanged: (v) => setState(() => _showStudio = v),
),
),
// ONE page-level notice when the hub says
@ -1657,24 +1656,24 @@ class _StoreGrid extends StatelessWidget {
});
Widget grid(List<StoreItem> gi) => GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: ChainSpace.md,
crossAxisSpacing: ChainSpace.md,
mainAxisExtent: 168,
),
itemCount: gi.length,
itemBuilder: (context, i) => _StoreCard(
item: gi[i],
locale: locale,
installedVersion: installedVersions[gi[i].name],
onTap: () => onTap(gi[i]),
onInstall: () => onInstall(gi[i]),
),
);
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: ChainSpace.md,
crossAxisSpacing: ChainSpace.md,
mainAxisExtent: 168,
),
itemCount: gi.length,
itemBuilder: (context, i) => _StoreCard(
item: gi[i],
locale: locale,
installedVersion: installedVersions[gi[i].name],
onTap: () => onTap(gi[i]),
onInstall: () => onInstall(gi[i]),
),
);
// A single category (e.g. the store is already filtered to
// one) renders without a redundant header.
@ -1695,20 +1694,17 @@ class _StoreGrid extends StatelessWidget {
children: [
Text(
_canonicalCatLabel(context, slug, labels[slug] ?? ''),
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: ChainSpace.sm),
Text(
'${groups[slug]!.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
@ -1932,18 +1928,14 @@ class _FeaturedTile extends StatelessWidget {
),
),
const SizedBox(width: ChainSpace.xs),
if (item.status.isNotEmpty)
_statusPill(context, item.status),
if (item.status.isNotEmpty) _statusPill(context, item.status),
// License on the card, not only in the detail
// sheet buyers scan the grid for exactly this
// (usertest finding: no license/cost signal per
// module before clicking).
if (item.license.isNotEmpty) ...[
const SizedBox(width: ChainSpace.xs),
ChainPill(
label: item.license,
tone: ChainPillTone.neutral,
),
ChainPill(label: item.license, tone: ChainPillTone.neutral),
],
const Spacer(),
if (item.installed)
@ -2230,7 +2222,9 @@ class _StoreDetailSheet extends StatefulWidget {
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)),
borderRadius: BorderRadius.vertical(
top: Radius.circular(ChainRadius.md),
),
),
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
);
@ -2314,8 +2308,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Future<void> _install() async {
// Same trust gate as the store card the detail sheet's
// install button must not be a quieter bypass.
final confirmed =
await ChainInstallConfirmDialog.show(context, widget.item);
final confirmed = await ChainInstallConfirmDialog.show(
context,
widget.item,
);
if (!confirmed || !mounted) return;
setState(() {
_busy = true;
@ -2686,6 +2682,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
_DocsPanel(text: _docsResult!.text),
const SizedBox(height: ChainSpace.lg),
],
if (item.dataSource != null)
StoreDataSourceSection(source: item.dataSource!),
StoreMaintainersSection(maintainers: item.maintainers),
if (item.repository.isNotEmpty) ...[
_SectionHeader(l.storeSectionSource),
@ -2781,7 +2779,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(ChainRadius.sm),
borderRadius: BorderRadius.circular(
ChainRadius.sm,
),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
@ -2969,7 +2969,11 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
mainAxisSize: MainAxisSize.min,
children: [
if (done) ...[
Icon(Icons.check_circle_outline, size: 32, color: ChainColors.success),
Icon(
Icons.check_circle_outline,
size: 32,
color: ChainColors.success,
),
const SizedBox(height: ChainSpace.md),
Text(
l.storeInstalledToast(_result!.name, _result!.version),
@ -3947,6 +3951,84 @@ class StoreMaintainersSection extends StatelessWidget {
}
}
/// Provenance block for `source.*` modules: who publishes the data
/// the module fetches, and under what terms. Sits above the install
/// button because it is a decision input, not a footnote the terms
/// of the material are separate from the module's own licence, and
/// an operator taking on an attribution duty should see it first.
/// Public so the widget test pumps it directly.
class StoreDataSourceSection extends StatelessWidget {
final DataProvenance source;
const StoreDataSourceSection({super.key, required this.source});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(l.storeSectionDataSource),
const SizedBox(height: ChainSpace.sm),
SelectableText(source.name, style: theme.textTheme.bodySmall),
if (source.url.isNotEmpty)
SelectableText(
source.url,
style: ChainTheme.mono(size: 11, color: theme.colorScheme.primary),
),
const SizedBox(height: ChainSpace.sm),
_ProvenanceRow(label: l.storeDataSourceLicense, value: source.license),
if (source.attribution.isNotEmpty)
_ProvenanceRow(
label: l.storeDataSourceAttribution,
value: source.attribution,
),
const SizedBox(height: ChainSpace.sm),
Text(l.storeDataSourceNote, style: muted),
const SizedBox(height: ChainSpace.lg),
],
);
}
}
/// Label and value on one line, value selectable so an attribution
/// string can be copied straight into a compliance note.
class _ProvenanceRow extends StatelessWidget {
final String label;
final String value;
const _ProvenanceRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(value, style: theme.textTheme.bodySmall),
),
],
),
);
}
}
/// Signature-verification warning pill. Same philosophy as the
/// provenance pill: the GOOD path (signature checked at install)
/// stays quiet, and so does the policy-off case that one is a