feat(settings): About section — vendor, versions, license, contact
Some checks are pending
Security / Security check (push) Waiting to run

Procurement personas found no vendor, version, license, or
support information anywhere in the app (a hard checklist fail
for regulated buyers). Settings gains an About category:
product name, Studio + running-hub version, vendor Flemming.AI,
author, Apache 2.0 license, contact address, and the docs URL —
every value selectable and one-click copyable. Studio version
constant moved to data/about_info.dart so sidebar tag and About
can never drift. Bumps Studio to 0.71.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-17 23:40:21 +02:00
parent c436e12601
commit c253f5d23e
9 changed files with 352 additions and 5 deletions

14
lib/data/about_info.dart Normal file
View file

@ -0,0 +1,14 @@
// Product identity shown in Settings About and the sidebar
// version tag. One place to bump so procurement-facing facts
// (vendor, license, contact) can never drift between surfaces.
/// Studio's own build version. Bump on every UI release so the
/// running app self-identifies.
const String kStudioVersion = '0.71.0';
const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)';
const String kAuthorName = 'Dr. Stefan Flemming';
const String kLicenseName = 'Apache 2.0';
const String kContactEmail = 'chain@flemming.ai';
const String kDocsUrl = 'https://chain.flemming.ai';

View file

@ -1187,6 +1187,22 @@ class HubService {
}
/// Composite "doctor" snapshot. One round-trip per piece, run
/// Version string of the RUNNING hub (from CheckUpdate's
/// local_version a local RPC field; the manifest fetch result
/// is ignored). Empty when the hub is unreachable. Used by
/// Settings About.
Future<String> hubVersion() async {
try {
final r = await _client.checkUpdate().timeout(
const Duration(seconds: 2),
onTimeout: () => CheckUpdateResponse(),
);
return r.localVersion;
} catch (_) {
return '';
}
}
/// in parallel so the page populates fast.
Future<DoctorSnapshot> doctor() async {
final results = await Future.wait([

View file

@ -1549,6 +1549,21 @@
"doctorPathStudioErrors": "Studio-Fehler",
"settingsCategoryGeneral": "Allgemein",
"settingsCategoryAppearance": "Darstellung",
"settingsCategoryAbout": "Über",
"aboutPanelTitle": "Über Ch∆In",
"aboutPanelBody": "Produkt-, Hersteller- und Lizenzangaben dieser Installation — alle Werte sind kopierbar.",
"aboutProduct": "Produkt",
"aboutStudioVersion": "Studio-Version",
"aboutHubVersion": "Hub-Version",
"aboutHubVersionUnknown": "nicht verbunden",
"aboutVendor": "Hersteller",
"aboutAuthor": "Autor",
"aboutLicense": "Lizenz",
"aboutContact": "Kontakt",
"aboutDocs": "Dokumentation",
"aboutOpenLink": "Im Browser öffnen",
"aboutCopyTooltip": "Kopieren",
"aboutCopiedToast": "In die Zwischenablage kopiert.",
"sidebarSearchLabel": "Suchen & Befehle",
"settingsSidebarPinnedTitle": "Navigation immer ausgeklappt",
"settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.",

View file

@ -1573,6 +1573,21 @@
"doctorPathStudioErrors": "Studio errors",
"settingsCategoryGeneral": "General",
"settingsCategoryAppearance": "Appearance",
"settingsCategoryAbout": "About",
"aboutPanelTitle": "About Ch∆In",
"aboutPanelBody": "Product, vendor, and license details of this installation — every value is copyable.",
"aboutProduct": "Product",
"aboutStudioVersion": "Studio version",
"aboutHubVersion": "Hub version",
"aboutHubVersionUnknown": "not connected",
"aboutVendor": "Vendor",
"aboutAuthor": "Author",
"aboutLicense": "License",
"aboutContact": "Contact",
"aboutDocs": "Documentation",
"aboutOpenLink": "Open in browser",
"aboutCopyTooltip": "Copy",
"aboutCopiedToast": "Copied to the clipboard.",
"sidebarSearchLabel": "Search & commands",
"settingsSidebarPinnedTitle": "Keep the navigation expanded",
"settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.",

View file

@ -4544,6 +4544,96 @@ abstract class AppLocalizations {
/// **'Appearance'**
String get settingsCategoryAppearance;
/// No description provided for @settingsCategoryAbout.
///
/// In en, this message translates to:
/// **'About'**
String get settingsCategoryAbout;
/// No description provided for @aboutPanelTitle.
///
/// In en, this message translates to:
/// **'About Ch∆In'**
String get aboutPanelTitle;
/// No description provided for @aboutPanelBody.
///
/// In en, this message translates to:
/// **'Product, vendor, and license details of this installation — every value is copyable.'**
String get aboutPanelBody;
/// No description provided for @aboutProduct.
///
/// In en, this message translates to:
/// **'Product'**
String get aboutProduct;
/// No description provided for @aboutStudioVersion.
///
/// In en, this message translates to:
/// **'Studio version'**
String get aboutStudioVersion;
/// No description provided for @aboutHubVersion.
///
/// In en, this message translates to:
/// **'Hub version'**
String get aboutHubVersion;
/// No description provided for @aboutHubVersionUnknown.
///
/// In en, this message translates to:
/// **'not connected'**
String get aboutHubVersionUnknown;
/// No description provided for @aboutVendor.
///
/// In en, this message translates to:
/// **'Vendor'**
String get aboutVendor;
/// No description provided for @aboutAuthor.
///
/// In en, this message translates to:
/// **'Author'**
String get aboutAuthor;
/// No description provided for @aboutLicense.
///
/// In en, this message translates to:
/// **'License'**
String get aboutLicense;
/// No description provided for @aboutContact.
///
/// In en, this message translates to:
/// **'Contact'**
String get aboutContact;
/// No description provided for @aboutDocs.
///
/// In en, this message translates to:
/// **'Documentation'**
String get aboutDocs;
/// No description provided for @aboutOpenLink.
///
/// In en, this message translates to:
/// **'Open in browser'**
String get aboutOpenLink;
/// No description provided for @aboutCopyTooltip.
///
/// In en, this message translates to:
/// **'Copy'**
String get aboutCopyTooltip;
/// No description provided for @aboutCopiedToast.
///
/// In en, this message translates to:
/// **'Copied to the clipboard.'**
String get aboutCopiedToast;
/// No description provided for @sidebarSearchLabel.
///
/// In en, this message translates to:

View file

@ -2666,6 +2666,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsCategoryAppearance => 'Darstellung';
@override
String get settingsCategoryAbout => 'Über';
@override
String get aboutPanelTitle => 'Über Ch∆In';
@override
String get aboutPanelBody =>
'Produkt-, Hersteller- und Lizenzangaben dieser Installation — alle Werte sind kopierbar.';
@override
String get aboutProduct => 'Produkt';
@override
String get aboutStudioVersion => 'Studio-Version';
@override
String get aboutHubVersion => 'Hub-Version';
@override
String get aboutHubVersionUnknown => 'nicht verbunden';
@override
String get aboutVendor => 'Hersteller';
@override
String get aboutAuthor => 'Autor';
@override
String get aboutLicense => 'Lizenz';
@override
String get aboutContact => 'Kontakt';
@override
String get aboutDocs => 'Dokumentation';
@override
String get aboutOpenLink => 'Im Browser öffnen';
@override
String get aboutCopyTooltip => 'Kopieren';
@override
String get aboutCopiedToast => 'In die Zwischenablage kopiert.';
@override
String get sidebarSearchLabel => 'Suchen & Befehle';

View file

@ -2668,6 +2668,52 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsCategoryAppearance => 'Appearance';
@override
String get settingsCategoryAbout => 'About';
@override
String get aboutPanelTitle => 'About Ch∆In';
@override
String get aboutPanelBody =>
'Product, vendor, and license details of this installation — every value is copyable.';
@override
String get aboutProduct => 'Product';
@override
String get aboutStudioVersion => 'Studio version';
@override
String get aboutHubVersion => 'Hub version';
@override
String get aboutHubVersionUnknown => 'not connected';
@override
String get aboutVendor => 'Vendor';
@override
String get aboutAuthor => 'Author';
@override
String get aboutLicense => 'License';
@override
String get aboutContact => 'Contact';
@override
String get aboutDocs => 'Documentation';
@override
String get aboutOpenLink => 'Open in browser';
@override
String get aboutCopyTooltip => 'Copy';
@override
String get aboutCopiedToast => 'Copied to the clipboard.';
@override
String get sidebarSearchLabel => 'Search & commands';

View file

@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/about_info.dart';
import 'data/chain_log.dart';
import 'data/error_presentation.dart';
import 'data/hub.dart';
@ -33,10 +34,9 @@ import 'theme/tokens.dart';
import 'widgets/chain_search_palette.dart';
import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.70.0';
/// Studio's own build version — canonical value lives in
/// data/about_info.dart (shared with Settings About).
export 'data/about_info.dart' show kStudioVersion;
/// Run one pre-frame restore step, absorbing any failure. Everything
/// before `runApp` is a black window waiting to happen: an exception

View file

@ -4,7 +4,9 @@
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import '../data/about_info.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/hub_auth_token.dart';
@ -41,7 +43,15 @@ class ChainSettingsDialog extends StatefulWidget {
/// Sidebar categories every Settings panel belongs to exactly
/// one. Order here drives the sidebar order and the
/// first-opened category.
enum _Category { general, appearance, ai, integrations, security, maintenance }
enum _Category {
general,
appearance,
ai,
integrations,
security,
maintenance,
about,
}
class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
late final TextEditingController _host;
@ -70,6 +80,10 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
/// auth-policy panel below re-queries with the new credentials.
int _authPolicyReloadTick = 0;
/// Running hub's version for the About panel. Null while
/// loading, empty string when the hub is unreachable.
String? _hubVersion;
@override
void initState() {
super.initState();
@ -83,6 +97,13 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
_loadN8nEndpoints();
_loadRegistryToken();
_loadHubAuthToken();
_loadHubVersion();
}
Future<void> _loadHubVersion() async {
final v = await HubService.instance.hubVersion();
if (!mounted) return;
setState(() => _hubVersion = v);
}
Future<void> _loadHubAuthToken() async {
@ -392,6 +413,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
_Category.integrations => _integrationsPanel(l, theme),
_Category.security => _securityPanel(l, theme),
_Category.maintenance => _maintenancePanel(l, theme),
_Category.about => _aboutPanel(l, theme),
};
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
@ -621,6 +643,39 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
];
}
List<Widget> _aboutPanel(AppLocalizations l, ThemeData theme) {
// Procurement-facing identity: who makes this, which
// versions run, under which license, whom to contact.
// Every value is selectable AND has a copy affordance.
return [
_panelTitle(l.aboutPanelTitle, l.aboutPanelBody, theme),
_AboutRow(label: l.aboutProduct, value: kProductName),
_AboutRow(label: l.aboutStudioVersion, value: kStudioVersion),
_AboutRow(
label: l.aboutHubVersion,
value: _hubVersion == null
? ''
: _hubVersion!.isEmpty
? l.aboutHubVersionUnknown
: _hubVersion!,
),
_AboutRow(label: l.aboutVendor, value: kVendorName),
_AboutRow(label: l.aboutAuthor, value: kAuthorName),
_AboutRow(label: l.aboutLicense, value: kLicenseName),
_AboutRow(label: l.aboutContact, value: kContactEmail),
_AboutRow(
label: l.aboutDocs,
value: kDocsUrl,
trailing: IconButton(
icon: const Icon(Icons.open_in_new, size: 16),
tooltip: l.aboutOpenLink,
visualDensity: VisualDensity.compact,
onPressed: () => SystemActions.openInOs(kDocsUrl),
),
),
];
}
List<Widget> _aiPanel(AppLocalizations l, ThemeData theme) {
return [
_panelTitle(
@ -779,6 +834,55 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
}
}
/// Label/value row of the About panel. The value is selectable
/// and carries an explicit copy button (procurement reviewers
/// paste these into checklists).
class _AboutRow extends StatelessWidget {
final String label;
final String value;
final Widget? trailing;
const _AboutRow({required this.label, required this.value, this.trailing});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: ChainSpace.xs),
child: Row(
children: [
SizedBox(
width: 150,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(value, style: theme.textTheme.bodyMedium),
),
IconButton(
icon: const Icon(Icons.copy_outlined, size: 14),
tooltip: l.aboutCopyTooltip,
visualDensity: VisualDensity.compact,
onPressed: () async {
await Clipboard.setData(ClipboardData(text: value));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.aboutCopiedToast)),
);
},
),
?trailing,
],
),
);
}
}
class _ChannelRow extends StatelessWidget {
final ChannelInfo channel;
final bool active;
@ -2635,6 +2739,7 @@ class _Sidebar extends StatelessWidget {
Icons.build_outlined,
l.settingsCategoryMaintenance,
),
(_Category.about, Icons.info_outline, l.settingsCategoryAbout),
];
return SizedBox(
width: 220,