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
|
|
@ -983,6 +983,14 @@ class HubService {
|
||||||
canonicalCategoryLabel: e.canonicalCategoryLabel,
|
canonicalCategoryLabel: e.canonicalCategoryLabel,
|
||||||
installVerification: e.installVerification,
|
installVerification: e.installVerification,
|
||||||
maintainers: e.maintainers,
|
maintainers: e.maintainers,
|
||||||
|
dataSource: e.hasDataSource()
|
||||||
|
? DataProvenance(
|
||||||
|
name: e.dataSource.name,
|
||||||
|
url: e.dataSource.url,
|
||||||
|
license: e.dataSource.license,
|
||||||
|
attribution: e.dataSource.attribution,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -2454,6 +2462,11 @@ class StoreItem {
|
||||||
/// any — the detail sheet renders an honest "not specified".
|
/// any — the detail sheet renders an honest "not specified".
|
||||||
final List<String> maintainers;
|
final List<String> maintainers;
|
||||||
|
|
||||||
|
/// Where a `source.*` module's data comes from, and under what
|
||||||
|
/// terms. Null for every other module: the module's own licence
|
||||||
|
/// covers its code, this covers material it reaches at runtime.
|
||||||
|
final DataProvenance? dataSource;
|
||||||
|
|
||||||
/// How an install of this entry would be verified under the
|
/// How an install of this entry would be verified under the
|
||||||
/// hub's CURRENT policy — computed hub-side with the same
|
/// hub's CURRENT policy — computed hub-side with the same
|
||||||
/// resolvers the install gate enforces, so this can never
|
/// resolvers the install gate enforces, so this can never
|
||||||
|
|
@ -2496,5 +2509,30 @@ class StoreItem {
|
||||||
this.canonicalCategoryLabel = '',
|
this.canonicalCategoryLabel = '',
|
||||||
this.installVerification = '',
|
this.installVerification = '',
|
||||||
this.maintainers = const [],
|
this.maintainers = const [],
|
||||||
|
this.dataSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance of the material a source module fetches.
|
||||||
|
class DataProvenance {
|
||||||
|
/// Publisher, as a person would name it.
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Canonical URL of the upstream source.
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// Terms in plain words, not SPDX: statutes carry no software
|
||||||
|
/// licence at all.
|
||||||
|
final String license;
|
||||||
|
|
||||||
|
/// Attribution the operator must carry with the output; empty when
|
||||||
|
/// the upstream requires none.
|
||||||
|
final String attribution;
|
||||||
|
|
||||||
|
const DataProvenance({
|
||||||
|
required this.name,
|
||||||
|
required this.url,
|
||||||
|
required this.license,
|
||||||
|
required this.attribution,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1670,6 +1670,10 @@
|
||||||
"installConfirmTrustTitle": "Vertrauen & Sicherheit",
|
"installConfirmTrustTitle": "Vertrauen & Sicherheit",
|
||||||
"installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.",
|
"installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.",
|
||||||
"storeSectionMaintainers": "Maintainer",
|
"storeSectionMaintainers": "Maintainer",
|
||||||
|
"storeSectionDataSource": "Datenquelle",
|
||||||
|
"storeDataSourceLicense": "Bedingungen",
|
||||||
|
"storeDataSourceAttribution": "Namensnennung erforderlich",
|
||||||
|
"storeDataSourceNote": "Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.",
|
||||||
"storeMaintainersNone": "nicht angegeben",
|
"storeMaintainersNone": "nicht angegeben",
|
||||||
"installConfirmMaintainers": "Maintainer",
|
"installConfirmMaintainers": "Maintainer",
|
||||||
"storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.",
|
"storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.",
|
||||||
|
|
|
||||||
|
|
@ -1711,6 +1711,10 @@
|
||||||
"installConfirmTrustTitle": "Trust & security",
|
"installConfirmTrustTitle": "Trust & security",
|
||||||
"installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.",
|
"installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.",
|
||||||
"storeSectionMaintainers": "Maintainers",
|
"storeSectionMaintainers": "Maintainers",
|
||||||
|
"storeSectionDataSource": "Data source",
|
||||||
|
"storeDataSourceLicense": "Terms",
|
||||||
|
"storeDataSourceAttribution": "Attribution required",
|
||||||
|
"storeDataSourceNote": "These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.",
|
||||||
"storeMaintainersNone": "not specified",
|
"storeMaintainersNone": "not specified",
|
||||||
"installConfirmMaintainers": "Maintainers",
|
"installConfirmMaintainers": "Maintainers",
|
||||||
"storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.",
|
"storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.",
|
||||||
|
|
|
||||||
|
|
@ -5174,6 +5174,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Maintainers'**
|
/// **'Maintainers'**
|
||||||
String get storeSectionMaintainers;
|
String get storeSectionMaintainers;
|
||||||
|
|
||||||
|
/// No description provided for @storeSectionDataSource.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Data source'**
|
||||||
|
String get storeSectionDataSource;
|
||||||
|
|
||||||
|
/// No description provided for @storeDataSourceLicense.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Terms'**
|
||||||
|
String get storeDataSourceLicense;
|
||||||
|
|
||||||
|
/// No description provided for @storeDataSourceAttribution.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Attribution required'**
|
||||||
|
String get storeDataSourceAttribution;
|
||||||
|
|
||||||
|
/// No description provided for @storeDataSourceNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'**
|
||||||
|
String get storeDataSourceNote;
|
||||||
|
|
||||||
/// No description provided for @storeMaintainersNone.
|
/// No description provided for @storeMaintainersNone.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3056,6 +3056,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get storeSectionMaintainers => 'Maintainer';
|
String get storeSectionMaintainers => 'Maintainer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSectionDataSource => 'Datenquelle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceLicense => 'Bedingungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceAttribution => 'Namensnennung erforderlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceNote =>
|
||||||
|
'Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeMaintainersNone => 'nicht angegeben';
|
String get storeMaintainersNone => 'nicht angegeben';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3052,6 +3052,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get storeSectionMaintainers => 'Maintainers';
|
String get storeSectionMaintainers => 'Maintainers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeSectionDataSource => 'Data source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceLicense => 'Terms';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceAttribution => 'Attribution required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeDataSourceNote =>
|
||||||
|
'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeMaintainersNone => 'not specified';
|
String get storeMaintainersNone => 'not specified';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,8 +253,9 @@ class _StorePageState extends State<StorePage> {
|
||||||
icon: const Icon(Icons.add_business_outlined, size: 16),
|
icon: const Icon(Icons.add_business_outlined, size: 16),
|
||||||
label: Text(l.storesManagerButton),
|
label: Text(l.storesManagerButton),
|
||||||
onPressed: () => ChainStoresDialog.show(context),
|
onPressed: () => ChainStoresDialog.show(context),
|
||||||
style:
|
style: OutlinedButton.styleFrom(
|
||||||
OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -365,8 +366,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
// Modules vs Studio plugins/themes — a theme
|
// Modules vs Studio plugins/themes — a theme
|
||||||
// extends the GUI, a module runs in a flow.
|
// extends the GUI, a module runs in a flow.
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding: const EdgeInsets.only(bottom: ChainSpace.md),
|
||||||
const EdgeInsets.only(bottom: ChainSpace.md),
|
|
||||||
child: ChainSegments<bool>(
|
child: ChainSegments<bool>(
|
||||||
items: [
|
items: [
|
||||||
ChainSegmentItem(
|
ChainSegmentItem(
|
||||||
|
|
@ -381,8 +381,7 @@ class _StorePageState extends State<StorePage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
value: _showStudio,
|
value: _showStudio,
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(() => _showStudio = v),
|
||||||
setState(() => _showStudio = v),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// ONE page-level notice when the hub says
|
// ONE page-level notice when the hub says
|
||||||
|
|
@ -1657,24 +1656,24 @@ class _StoreGrid extends StatelessWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget grid(List<StoreItem> gi) => GridView.builder(
|
Widget grid(List<StoreItem> gi) => GridView.builder(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: cols,
|
crossAxisCount: cols,
|
||||||
mainAxisSpacing: ChainSpace.md,
|
mainAxisSpacing: ChainSpace.md,
|
||||||
crossAxisSpacing: ChainSpace.md,
|
crossAxisSpacing: ChainSpace.md,
|
||||||
mainAxisExtent: 168,
|
mainAxisExtent: 168,
|
||||||
),
|
),
|
||||||
itemCount: gi.length,
|
itemCount: gi.length,
|
||||||
itemBuilder: (context, i) => _StoreCard(
|
itemBuilder: (context, i) => _StoreCard(
|
||||||
item: gi[i],
|
item: gi[i],
|
||||||
locale: locale,
|
locale: locale,
|
||||||
installedVersion: installedVersions[gi[i].name],
|
installedVersion: installedVersions[gi[i].name],
|
||||||
onTap: () => onTap(gi[i]),
|
onTap: () => onTap(gi[i]),
|
||||||
onInstall: () => onInstall(gi[i]),
|
onInstall: () => onInstall(gi[i]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// A single category (e.g. the store is already filtered to
|
// A single category (e.g. the store is already filtered to
|
||||||
// one) renders without a redundant header.
|
// one) renders without a redundant header.
|
||||||
|
|
@ -1695,20 +1694,17 @@ class _StoreGrid extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_canonicalCatLabel(context, slug, labels[slug] ?? ''),
|
_canonicalCatLabel(context, slug, labels[slug] ?? ''),
|
||||||
style: Theme.of(context)
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
.textTheme
|
fontWeight: FontWeight.w700,
|
||||||
.titleSmall
|
),
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Text(
|
Text(
|
||||||
'${groups[slug]!.length}',
|
'${groups[slug]!.length}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
.colorScheme
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
.onSurfaceVariant,
|
),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1932,18 +1928,14 @@ class _FeaturedTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.xs),
|
const SizedBox(width: ChainSpace.xs),
|
||||||
if (item.status.isNotEmpty)
|
if (item.status.isNotEmpty) _statusPill(context, item.status),
|
||||||
_statusPill(context, item.status),
|
|
||||||
// License on the card, not only in the detail
|
// License on the card, not only in the detail
|
||||||
// sheet — buyers scan the grid for exactly this
|
// sheet — buyers scan the grid for exactly this
|
||||||
// (usertest finding: no license/cost signal per
|
// (usertest finding: no license/cost signal per
|
||||||
// module before clicking).
|
// module before clicking).
|
||||||
if (item.license.isNotEmpty) ...[
|
if (item.license.isNotEmpty) ...[
|
||||||
const SizedBox(width: ChainSpace.xs),
|
const SizedBox(width: ChainSpace.xs),
|
||||||
ChainPill(
|
ChainPill(label: item.license, tone: ChainPillTone.neutral),
|
||||||
label: item.license,
|
|
||||||
tone: ChainPillTone.neutral,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (item.installed)
|
if (item.installed)
|
||||||
|
|
@ -2230,7 +2222,9 @@ class _StoreDetailSheet extends StatefulWidget {
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shape: const RoundedRectangleBorder(
|
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),
|
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
|
||||||
);
|
);
|
||||||
|
|
@ -2314,8 +2308,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
Future<void> _install() async {
|
Future<void> _install() async {
|
||||||
// Same trust gate as the store card — the detail sheet's
|
// Same trust gate as the store card — the detail sheet's
|
||||||
// install button must not be a quieter bypass.
|
// install button must not be a quieter bypass.
|
||||||
final confirmed =
|
final confirmed = await ChainInstallConfirmDialog.show(
|
||||||
await ChainInstallConfirmDialog.show(context, widget.item);
|
context,
|
||||||
|
widget.item,
|
||||||
|
);
|
||||||
if (!confirmed || !mounted) return;
|
if (!confirmed || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
|
|
@ -2686,6 +2682,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
_DocsPanel(text: _docsResult!.text),
|
_DocsPanel(text: _docsResult!.text),
|
||||||
const SizedBox(height: ChainSpace.lg),
|
const SizedBox(height: ChainSpace.lg),
|
||||||
],
|
],
|
||||||
|
if (item.dataSource != null)
|
||||||
|
StoreDataSourceSection(source: item.dataSource!),
|
||||||
StoreMaintainersSection(maintainers: item.maintainers),
|
StoreMaintainersSection(maintainers: item.maintainers),
|
||||||
if (item.repository.isNotEmpty) ...[
|
if (item.repository.isNotEmpty) ...[
|
||||||
_SectionHeader(l.storeSectionSource),
|
_SectionHeader(l.storeSectionSource),
|
||||||
|
|
@ -2781,7 +2779,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainer,
|
color: theme.colorScheme.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
borderRadius: BorderRadius.circular(
|
||||||
|
ChainRadius.sm,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.colorScheme.outlineVariant,
|
color: theme.colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -2969,7 +2969,11 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (done) ...[
|
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),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Text(
|
Text(
|
||||||
l.storeInstalledToast(_result!.name, _result!.version),
|
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
|
/// Signature-verification warning pill. Same philosophy as the
|
||||||
/// provenance pill: the GOOD path (signature checked at install)
|
/// provenance pill: the GOOD path (signature checked at install)
|
||||||
/// stays quiet, and so does the policy-off case — that one is a
|
/// stays quiet, and so does the policy-off case — that one is a
|
||||||
|
|
|
||||||
85
test/store_data_source_test.dart
Normal file
85
test/store_data_source_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Guards for the data-provenance block in the store detail sheet.
|
||||||
|
//
|
||||||
|
// The rule it protects (docs/architecture/store-format.md): the terms
|
||||||
|
// of the material a source module fetches are separate from the
|
||||||
|
// module's own licence, and an operator must see them before the
|
||||||
|
// install button, not in a README afterwards.
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
|
import 'package:chain_studio/pages/store.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Widget _host(Widget child) => MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('de'),
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const bund = DataProvenance(
|
||||||
|
name: 'gesetze-im-internet.de (BMJ / juris GmbH)',
|
||||||
|
url: 'https://www.gesetze-im-internet.de/',
|
||||||
|
license: 'Amtliches Werk (§ 5 UrhG), gemeinfrei',
|
||||||
|
attribution: 'Quelle: gesetze-im-internet.de (Bundesministerium der Justiz)',
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('publisher, terms and attribution are all shown',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(bund.name), findsOneWidget);
|
||||||
|
expect(find.text(bund.url), findsOneWidget);
|
||||||
|
expect(find.text(bund.license), findsOneWidget);
|
||||||
|
expect(find.text(bund.attribution), findsOneWidget,
|
||||||
|
reason: 'an attribution duty must be visible before installing');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the attribution row is omitted when none is required',
|
||||||
|
(tester) async {
|
||||||
|
const noAttribution = DataProvenance(
|
||||||
|
name: 'Vom Betreiber gepflegter Metadaten-Katalog',
|
||||||
|
url: '',
|
||||||
|
license: 'Katalog des Betreibers',
|
||||||
|
attribution: '',
|
||||||
|
);
|
||||||
|
await tester
|
||||||
|
.pumpWidget(_host(const StoreDataSourceSection(source: noAttribution)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(noAttribution.name), findsOneWidget);
|
||||||
|
final l = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(StoreDataSourceSection)),
|
||||||
|
)!;
|
||||||
|
expect(find.text(l.storeDataSourceAttribution), findsNothing,
|
||||||
|
reason: 'an empty attribution must not render an empty row');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the terms are selectable so they can be copied',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Compliance notes get written by copying, not retyping.
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is SelectableText && w.data == bund.attribution,
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the note says whose terms these are', (tester) async {
|
||||||
|
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final l = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(StoreDataSourceSection)),
|
||||||
|
)!;
|
||||||
|
// Without this line an operator reads the terms as the module's.
|
||||||
|
expect(find.text(l.storeDataSourceNote), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue