diff --git a/lib/solidui.dart b/lib/solidui.dart index 1e5301c..13908b8 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -97,6 +97,9 @@ export 'src/widgets/solid_owner_avatar.dart'; export 'src/widgets/solid_profile_avatar.dart'; export 'src/widgets/solid_profile_crop_dialog.dart'; export 'src/widgets/solid_profile_editor.dart'; +export 'src/widgets/solid_webid_section.dart'; +export 'src/widgets/solid_link_pod_dialog.dart'; +export 'src/services/solid_webid_service.dart'; export 'src/widgets/secret_text_field.dart'; export 'src/widgets/security_key_ui.dart'; diff --git a/lib/src/services/solid_webid_service.dart b/lib/src/services/solid_webid_service.dart new file mode 100644 index 0000000..01c0b91 --- /dev/null +++ b/lib/src/services/solid_webid_service.dart @@ -0,0 +1,180 @@ +/// Service for reading and patching the user's own WebID document. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'dart:convert' show utf8; + +import 'package:solidpod/solidpod.dart'; + +/// The `solid:` terms namespace used for OIDC issuer discovery and +/// registration-token verification on a WebID document. + +const String _solidTermsNs = 'http://www.w3.org/ns/solid/terms#'; + +const String _oidcIssuerPredicate = '${_solidTermsNs}oidcIssuer'; +const String _oidcIssuerRegistrationTokenPredicate = + '${_solidTermsNs}oidcIssuerRegistrationToken'; + +/// Reads and patches the RDF triples on the logged-in user's own WebID +/// document — specifically the `solid:oidcIssuerRegistrationToken` and +/// `solid:oidcIssuer` triples used to link the WebID to another Solid Pod +/// server's login. All writes are targeted SPARQL PATCH updates so the rest +/// of the WebID document (name, storage, other triples) is left untouched. + +class SolidWebIdService { + SolidWebIdService._(); + + static final SolidWebIdService _instance = SolidWebIdService._(); + + /// Singleton accessor. + + static SolidWebIdService get instance => _instance; + + /// Resolves the logged-in user's WebID together with its document URL + /// (the WebID with any `#fragment` stripped). + + Future<({String webId, String docUrl})> _current() async { + final webId = await getWebId(); + if (webId == null || webId.isEmpty) { + throw StateError('Cannot resolve the WebID: not logged in.'); + } + return (webId: webId, docUrl: webId.split('#').first); + } + + /// Fetches the raw turtle content of the logged-in user's own WebID + /// document. + + Future fetchWebIdTurtle() async { + if (!await isUserLoggedIn()) { + throw StateError('You must be logged in to view your WebID.'); + } + final docUrl = (await _current()).docUrl; + final bytes = await getResource(docUrl); + return utf8.decode(bytes); + } + + /// Looks for a pending `solid:oidcIssuerRegistrationToken` triple in + /// [turtle] (as previously fetched by [fetchWebIdTurtle]) and returns its + /// literal value, or `null` when none is present. + + String? findPendingRegistrationToken(String turtle) { + final values = _valuesOf(turtle, _oidcIssuerRegistrationTokenPredicate); + return values.isEmpty ? null : values.first; + } + + /// Returns every `solid:oidcIssuer` already registered in [turtle] (as + /// previously fetched by [fetchWebIdTurtle]), normalised (trailing slash + /// stripped) for comparison against a candidate Pod URL. + + List currentOidcIssuers(String turtle) => + _valuesOf(turtle, _oidcIssuerPredicate).map(_normalizeUrl).toList(); + + // Collects every literal/URI value of [predicate] on any subject in the + // (already-fetched) [turtle]. + + List _valuesOf(String turtle, String predicate) { + Map> map; + try { + map = turtleToTripleMap(turtle); + } catch (_) { + return const []; + } + final values = []; + for (final entry in map.values) { + final value = entry[predicate]; + if (value is String && value.trim().isNotEmpty) { + values.add(value); + } else if (value is Iterable) { + for (final item in value) { + if (item is String && item.trim().isNotEmpty) values.add(item); + } + } + } + return values; + } + + // Strips a trailing slash and surrounding whitespace so Pod URLs compare + // equal regardless of how they were entered/stored. + + String _normalizeUrl(String url) { + final trimmed = url.trim(); + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; + } + + /// Adds a `solid:oidcIssuerRegistrationToken` triple carrying [token] to + /// the user's WebID document, proving ownership to another Pod server + /// that is being linked. + + Future addRegistrationToken(String token) async { + final (:webId, :docUrl) = await _current(); + final escaped = _escapeLiteral(token); + final query = 'INSERT DATA ' + '{<$webId> <$_oidcIssuerRegistrationTokenPredicate> "$escaped"};'; + await updateFileByQuery(docUrl, query); + } + + /// Removes the `solid:oidcIssuer` triple pointing at [podIssuerUrl] and + /// adds a new `solid:oidcIssuerRegistrationToken` triple carrying [token], + /// in a single PATCH. Used to relink a Pod that is already registered as + /// an issuer — e.g. after the account on that Pod server was recreated and + /// needs to be linked again from scratch. + + Future removeIssuerAndAddToken( + String podIssuerUrl, + String token, + ) async { + final (:webId, :docUrl) = await _current(); + final issuerUrl = _normalizeUrl(podIssuerUrl); + final escaped = _escapeLiteral(token); + final query = 'DELETE DATA {<$webId> <$_oidcIssuerPredicate>' + ' <$issuerUrl>}; INSERT DATA {<$webId>' + ' <$_oidcIssuerRegistrationTokenPredicate> "$escaped"};'; + await updateFileByQuery(docUrl, query); + } + + /// Removes any `solid:oidcIssuerRegistrationToken` triple and adds a + /// `solid:oidcIssuer` triple pointing at [podIssuerUrl], completing a Pod + /// link once the other Pod server has verified the token. + + Future completeLink(String podIssuerUrl) async { + final (:webId, :docUrl) = await _current(); + final issuerUrl = _normalizeUrl(podIssuerUrl); + final query = 'DELETE {<$webId> <$_oidcIssuerRegistrationTokenPredicate>' + ' ?o} WHERE {<$webId> <$_oidcIssuerRegistrationTokenPredicate> ?o};' + ' INSERT DATA {<$webId> <$_oidcIssuerPredicate> <$issuerUrl>};'; + await updateFileByQuery(docUrl, query); + } + + // Escapes characters that would otherwise break out of the SPARQL string + // literal when interpolating untrusted user input (the registration + // token). + + String _escapeLiteral(String value) => + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} diff --git a/lib/src/widgets/solid_link_pod_dialog.dart b/lib/src/widgets/solid_link_pod_dialog.dart new file mode 100644 index 0000000..bfce10b --- /dev/null +++ b/lib/src/widgets/solid_link_pod_dialog.dart @@ -0,0 +1,428 @@ +/// A dialog for linking the user's WebID to another Solid Pod server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'package:flutter/material.dart'; + +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:gap/gap.dart'; + +import 'package:solidui/src/services/solid_webid_service.dart'; +import 'package:solidui/src/utils/snack_bar.dart'; + +/// Which step of the Pod-linking flow the dialog is currently showing. + +enum _LinkStage { + /// Collect the target Pod's server URL and registration token, then add + /// the `solid:oidcIssuerRegistrationToken` proof-of-ownership triple. + + addToken, + + /// A registration token is already present on the WebID (just added, or + /// found on reopen) — collect/confirm the Pod URL and finish the link by + /// removing the token and adding the `solid:oidcIssuer` triple. + + finish, +} + +/// Guides the user through linking their WebID to another Solid Pod server: +/// +/// 1. **Add Token** — writes the `oidcIssuerRegistrationToken` triple the +/// other Pod's account page asked for, so the user can go verify it +/// there. +/// 2. **Finish Linking** — once verified externally, removes the token +/// triple and adds a `solid:oidcIssuer` triple pointing at that Pod, so +/// Solid-OIDC-aware apps can discover it from the WebID. +/// +/// Reopening the dialog re-detects a pending token already on the WebID and +/// jumps straight to the finish step. + +class SolidLinkPodDialog extends StatefulWidget { + const SolidLinkPodDialog({super.key}); + + /// Opens the dialog. + + static Future show(BuildContext context) => showDialog( + context: context, + builder: (_) => const SolidLinkPodDialog(), + ); + + @override + State createState() => _SolidLinkPodDialogState(); +} + +class _SolidLinkPodDialogState extends State { + static const _podUrlField = 'pod_url'; + static const _tokenField = 'registration_token'; + + final _formKey = GlobalKey(); + + bool _initialising = true; + bool _busy = false; + String? _message; + bool _messageIsError = false; + + _LinkStage _stage = _LinkStage.addToken; + String? _podUrl; + + // True once _handleAddToken has detected that the entered Pod URL is + // already registered as a solid:oidcIssuer — the primary action then + // switches to removing that issuer triple before adding the token, so the + // Pod can be relinked from scratch. + + bool _issuerAlreadyLinked = false; + + @override + void initState() { + super.initState(); + _detectStage(); + } + + Future _detectStage() async { + try { + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + final pending = + SolidWebIdService.instance.findPendingRegistrationToken(turtle); + if (mounted && pending != null) { + setState(() => _stage = _LinkStage.finish); + } + } catch (_) { + // Fall back to the add-token stage; the form itself will surface any + // real error on submit. + } finally { + if (mounted) setState(() => _initialising = false); + } + } + + void _setMessage(String message, {bool error = false}) { + if (!mounted) return; + setState(() { + _message = message; + _messageIsError = error; + }); + } + + // Stage 1: add the registration token proving WebID ownership. + + Future _handleAddToken() async { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + + final values = _formKey.currentState!.value; + final podUrl = values[_podUrlField].toString().trim(); + final token = values[_tokenField].toString().trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + // Re-fetch immediately before writing so a Pod added since the dialog + // opened is still caught. + + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + final existingIssuers = + SolidWebIdService.instance.currentOidcIssuers(turtle); + final normalizedPodUrl = podUrl.endsWith('/') + ? podUrl.substring(0, podUrl.length - 1) + : podUrl; + if (existingIssuers.contains(normalizedPodUrl)) { + setState(() => _issuerAlreadyLinked = true); + _setMessage( + '"$podUrl" is already linked as an OIDC issuer on your WebID. If ' + 'you need to relink this Pod (e.g. its account was recreated), ' + 'click "Remove Issuer & Add Token" below to remove the existing ' + 'link and start over.', + error: true, + ); + return; + } + + await SolidWebIdService.instance.addRegistrationToken(token); + setState(() { + _podUrl = podUrl; + _stage = _LinkStage.finish; + }); + _setMessage( + 'Verification token added. Now go back to "$podUrl" and click ' + '"Link WebID to account" again there to verify. Once verified, click ' + '"Finish Linking" below.', + ); + } on Object catch (e) { + _setMessage('Failed to add the verification token: $e', error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Stage 1 (relink path): the Pod is already registered as an issuer — + // remove that triple and add the registration token in one PATCH, so the + // Pod can be re-verified from scratch. + + Future _handleRemoveIssuerAndAddToken() async { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + + final values = _formKey.currentState!.value; + final podUrl = values[_podUrlField].toString().trim(); + final token = values[_tokenField].toString().trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + await SolidWebIdService.instance.removeIssuerAndAddToken(podUrl, token); + setState(() { + _podUrl = podUrl; + _stage = _LinkStage.finish; + _issuerAlreadyLinked = false; + }); + _setMessage( + 'Removed the existing issuer link and added a new verification ' + 'token. Now go back to "$podUrl" and click "Link WebID to account" ' + 'again there to verify. Once verified, click "Finish Linking" ' + 'below.', + ); + } on Object catch (e) { + _setMessage( + 'Failed to remove the existing issuer link: $e', + error: true, + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Stage 2: remove the token and register the Pod as an OIDC issuer. + + Future _handleFinishLinking() async { + final podUrl = _podUrl ?? _formKey.currentState?.value[_podUrlField]; + if (podUrl == null || podUrl.toString().trim().isEmpty) { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + } + final resolvedPodUrl = + (_podUrl ?? _formKey.currentState!.value[_podUrlField].toString()) + .trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + await SolidWebIdService.instance.completeLink(resolvedPodUrl); + if (mounted) { + Navigator.of(context).pop(); + showSnackBar( + context, + 'Linked your WebID to "$resolvedPodUrl".', + Colors.green, + ); + } + } on Object catch (e) { + _setMessage('Failed to finish linking: $e', error: true); + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.link), + SizedBox(width: 12), + Text('Link another Pod'), + ], + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: _initialising + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + : FormBuilder( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _stage == _LinkStage.addToken + ? 'Enter the Pod server you want to link and the ' + 'verification token it gave you. This adds a ' + 'temporary triple to your WebID proving you ' + 'own it.' + : 'Enter the Pod server you are linking (if not ' + 'already filled in). This removes the ' + 'temporary verification token and registers ' + 'the Pod server as a login issuer on your WebID.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const Gap(16), + if (_message != null) ...[ + _MessageBanner( + message: _message!, + isError: _messageIsError, + colorScheme: cs, + ), + const Gap(12), + ], + FormBuilderTextField( + name: _podUrlField, + initialValue: _podUrl, + enabled: !_busy, + decoration: const InputDecoration( + labelText: 'Pod Server URL', + hintText: 'https://pods.example.org', + border: OutlineInputBorder(), + ), + // A stale "already linked" warning (and its Remove + // Issuer action) no longer applies once the URL + // changes. + onChanged: (_) { + if (_issuerAlreadyLinked) { + setState(() => _issuerAlreadyLinked = false); + } + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required( + errorText: 'Please enter the Pod server URL.', + ), + FormBuilderValidators.url( + errorText: 'Please enter a valid URL.', + ), + ]), + ), + if (_stage == _LinkStage.addToken) ...[ + const Gap(12), + FormBuilderTextField( + name: _tokenField, + enabled: !_busy, + decoration: const InputDecoration( + labelText: 'Registration Token', + border: OutlineInputBorder(), + ), + validator: FormBuilderValidators.required( + errorText: 'Please enter the registration token.', + ), + ), + ], + if (_busy) ...[ + const Gap(20), + const Center(child: CircularProgressIndicator()), + ], + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (!_initialising) + FilledButton( + onPressed: _busy + ? null + : (_stage == _LinkStage.addToken + ? (_issuerAlreadyLinked + ? _handleRemoveIssuerAndAddToken + : _handleAddToken) + : _handleFinishLinking), + child: Text( + _stage == _LinkStage.addToken + ? (_issuerAlreadyLinked + ? 'Remove Issuer & Add Token' + : 'Add Token') + : 'Finish Linking', + ), + ), + ], + ); + } +} + +// A compact status banner mirroring solid_backup_dialog.dart's message +// style. + +class _MessageBanner extends StatelessWidget { + const _MessageBanner({ + required this.message, + required this.isError, + required this.colorScheme, + }); + + final String message; + final bool isError; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final background = + isError ? colorScheme.errorContainer : colorScheme.secondaryContainer; + final foreground = isError + ? colorScheme.onErrorContainer + : colorScheme.onSecondaryContainer; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.check_circle_outline, + size: 20, + color: foreground, + ), + const Gap(8), + Expanded( + child: Text( + message, + style: TextStyle(color: foreground), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/solid_profile_editor.dart b/lib/src/widgets/solid_profile_editor.dart index 37aaad3..c2b6b15 100644 --- a/lib/src/widgets/solid_profile_editor.dart +++ b/lib/src/widgets/solid_profile_editor.dart @@ -33,11 +33,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:solidpod/solidpod.dart' show isUserLoggedIn; import 'package:solidui/src/services/solid_profile_notifier.dart'; import 'package:solidui/src/services/solid_profile_service.dart'; import 'package:solidui/src/widgets/solid_profile_avatar.dart'; import 'package:solidui/src/widgets/solid_profile_crop_dialog.dart'; +import 'package:solidui/src/widgets/solid_webid_section.dart'; /// A dialog that lets the user upload/change/delete a profile picture and /// set a display name. Changes are persisted to the user's Solid POD. @@ -65,6 +67,7 @@ class _SolidProfileEditorState extends State { bool _avatarRemoved = false; bool _isSaving = false; late SolidProfilePrivacy _pendingPrivacy; + bool _loggedIn = false; @override void initState() { @@ -74,6 +77,13 @@ class _SolidProfileEditorState extends State { ); _pendingAvatar = solidProfileNotifier.avatarBytes; _pendingPrivacy = solidProfileNotifier.privacy; + + // The WebID section reads/writes the user's own Pod, so it only makes + // sense to show it while a session is active. + + isUserLoggedIn().then((loggedIn) { + if (mounted) setState(() => _loggedIn = loggedIn); + }); } @override @@ -245,11 +255,22 @@ class _SolidProfileEditorState extends State { final theme = Theme.of(context); final hasAvatar = _pendingAvatar != null && _pendingAvatar!.isNotEmpty; + // Scale the dialog with the window instead of a fixed width — the raw + // WebID turtle content in SolidWebIdSection is much easier to read with + // more horizontal room on larger windows, while narrow/mobile windows + // still get a dialog sized to fit comfortably. + + final windowSize = MediaQuery.of(context).size; + final dialogWidth = (windowSize.width * 0.9).clamp(320.0, 640.0); + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Padding( + constraints: BoxConstraints( + maxWidth: dialogWidth, + maxHeight: windowSize.height * 0.9, + ), + child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, @@ -336,6 +357,16 @@ class _SolidProfileEditorState extends State { _buildPrivacySelector(theme), + if (_loggedIn) ...[ + const SizedBox(height: 16), + + // WebID viewer and Pod-linking entry point. Only shown while + // logged in since it reads/writes the user's own WebID + // document on their Pod. + + const SolidWebIdSection(), + ], + const SizedBox(height: 24), // Action buttons. diff --git a/lib/src/widgets/solid_webid_section.dart b/lib/src/widgets/solid_webid_section.dart new file mode 100644 index 0000000..8d957c7 --- /dev/null +++ b/lib/src/widgets/solid_webid_section.dart @@ -0,0 +1,191 @@ +/// Read-only WebID viewer and Pod-linking entry point for the Settings +/// dialog. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show getWebId; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:solidui/src/services/solid_webid_service.dart'; +import 'package:solidui/src/widgets/solid_link_pod_dialog.dart'; + +/// Shows the logged-in user's WebID and the raw turtle content of their +/// WebID document, with a "Link another Pod" action that opens +/// [SolidLinkPodDialog] to add proof-of-ownership and OIDC-issuer triples +/// needed to register the WebID as a login on another Solid Pod server. + +class SolidWebIdSection extends StatefulWidget { + const SolidWebIdSection({super.key}); + + @override + State createState() => _SolidWebIdSectionState(); +} + +class _SolidWebIdSectionState extends State { + bool _loading = true; + String? _webId; + String? _turtle; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final webId = await getWebId(); + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + if (!mounted) return; + setState(() { + _webId = webId; + _turtle = turtle; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Failed to load your WebID: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _openLinkPodDialog() async { + await SolidLinkPodDialog.show(context); + + // Refresh so the displayed turtle reflects any triples the dialog added + // or removed. + + if (mounted) await _load(); + } + + // Opens the WebID in the default browser, mirroring solid_status_bar.dart. + + Future _launchWebId() async { + final webId = _webId; + if (webId == null) return; + final uri = Uri.parse(webId); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.badge_outlined, + size: 18, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + 'Your WebID', + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + if (_webId != null) ...[ + const SizedBox(height: 4), + MouseRegion( + cursor: SystemMouseCursors.click, + child: SelectableText( + _webId!, + maxLines: 2, + onTap: _launchWebId, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + const SizedBox(height: 8), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (_error != null) + Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ) + else + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 160), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + _turtle ?? '', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _loading ? null : _openLinkPodDialog, + icon: const Icon(Icons.link, size: 18), + label: const Text('Link another Pod'), + ), + ), + ], + ), + ); + } +}