feat(studio): show a source module's data terms before the install button
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
fb809a4cbd
commit
35a79d0bb6
8 changed files with 307 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue