From 59eb573beeaf9d863511c9b730f5cd27ea2dfb8d Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 21 May 2026 22:02:05 +1000 Subject: [PATCH 1/5] Differentiate notes by note owner --- lib/solidui.dart | 2 + .../services/solid_owner_profile_service.dart | 305 ++++++++++++++++++ lib/src/widgets/solid_logout_dialog.dart | 4 + lib/src/widgets/solid_owner_avatar.dart | 281 ++++++++++++++++ 4 files changed, 592 insertions(+) create mode 100644 lib/src/services/solid_owner_profile_service.dart create mode 100644 lib/src/widgets/solid_owner_avatar.dart diff --git a/lib/solidui.dart b/lib/solidui.dart index ae83d0ec..f6d51a66 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -85,8 +85,10 @@ export 'src/services/solid_security_key_service.dart'; export 'src/services/solid_login_status_notifier.dart'; export 'src/services/solid_security_key_notifier.dart'; +export 'src/services/solid_owner_profile_service.dart'; export 'src/services/solid_profile_notifier.dart'; export 'src/services/solid_profile_service.dart'; +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'; diff --git a/lib/src/services/solid_owner_profile_service.dart b/lib/src/services/solid_owner_profile_service.dart new file mode 100644 index 00000000..9dddde2a --- /dev/null +++ b/lib/src/services/solid_owner_profile_service.dart @@ -0,0 +1,305 @@ +/// Service for fetching public profile data (avatar + display name) for any +/// POD owner identified by their WebID. +/// +/// 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. +/// +/// Authors: Tony Chen + +library; + +import 'dart:async'; +import 'dart:convert' show base64Decode; + +import 'package:flutter/foundation.dart'; + +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/src/utils/web_id_parser.dart'; + +/// Snapshot of a remote POD owner's profile derived from publicly readable +/// resources in their app profile folder. +/// +/// `null` fields indicate the corresponding data could not be fetched (either +/// because the resource does not exist, the owner has not made it public, or +/// a network error occurred). [isLoaded] is `true` once at least one fetch +/// attempt has completed, regardless of outcome. + +@immutable +class SolidOwnerProfile { + /// Bytes of the owner's profile picture (typically PNG), if any. + + final Uint8List? avatarBytes; + + /// Display name (foaf:name / vcard:fn), if any. + + final String? displayName; + + /// Whether a fetch has completed at least once for this owner. + + final bool isLoaded; + + const SolidOwnerProfile({ + this.avatarBytes, + this.displayName, + this.isLoaded = false, + }); + + /// Convenience empty/loaded marker used as the default cache value when a + /// fetch attempt produced no usable data. + + static const empty = SolidOwnerProfile(isLoaded: true); + + /// Whether [avatarBytes] holds a non-empty image payload. + + bool get hasAvatar => avatarBytes != null && avatarBytes!.isNotEmpty; + + /// Whether [displayName] holds a non-empty, non-whitespace string. + + bool get hasDisplayName => + displayName != null && displayName!.trim().isNotEmpty; +} + +/// Singleton that fetches and caches public profile data (avatar bytes + +/// display name) for arbitrary POD owners, keyed by WebID. +/// +/// The data is read from the owner's `/profile/avatar.ttl` and +/// `/profile/display-name.ttl` resources, written by +/// [SolidProfileService] when the user opts to keep their profile public. +/// Private (encrypted) profiles cannot be decrypted by other users and will +/// simply resolve to [SolidOwnerProfile.empty]. +/// +/// Results are cached in memory for the lifetime of the process so list views +/// do not refetch on every rebuild. Call [clearCache] on logout to drop the +/// previous user's view of the world. + +class SolidOwnerProfileService { + SolidOwnerProfileService._(); + + /// Singleton accessor. + + static final SolidOwnerProfileService instance = SolidOwnerProfileService._(); + + final Map _cache = {}; + final Map> _inflight = {}; + + /// Synchronously returns the cached profile for [webId], or `null` when no + /// fetch attempt has completed for this owner yet. + + SolidOwnerProfile? cachedProfile(String webId) => _cache[webId]; + + /// Fetches the public profile for [webId], caching the result. Concurrent + /// calls for the same WebID share a single network request. + + Future fetchProfile(String webId) { + final cached = _cache[webId]; + if (cached != null) return Future.value(cached); + return _inflight.putIfAbsent(webId, () => _fetch(webId)); + } + + /// Removes any cached entry for [webId] so that the next [fetchProfile] + /// call goes back to the network. + + void invalidate(String webId) { + _cache.remove(webId); + _inflight.remove(webId); + } + + /// Clears every cached profile. Call this on logout. + + void clearCache() { + _cache.clear(); + _inflight.clear(); + } + + // Network. + + Future _fetch(String webId) async { + try { + final parts = WebIdParts.tryParse(webId); + if (parts == null || parts.username.isEmpty) { + return _cache[webId] = SolidOwnerProfile.empty; + } + + // The profile resources are written by [SolidProfileService] under the + // current app's directory. Resolve them on the owner's POD using the + // same convention. + + final appDir = SolidConstants.directories.app; + if (appDir.isEmpty) { + return _cache[webId] = SolidOwnerProfile.empty; + } + final profileBase = + '${parts.serverUri}/${parts.username}/$appDir/$profileDir'; + final avatarUrl = '$profileBase/$profilePictureFile'; + final displayNameUrl = '$profileBase/$displayNameFile'; + + final results = await Future.wait([ + _safeFetchAvatar(avatarUrl), + _safeFetchDisplayName(displayNameUrl), + ]); + + final profile = SolidOwnerProfile( + avatarBytes: results[0] as Uint8List?, + displayName: results[1] as String?, + isLoaded: true, + ); + _cache[webId] = profile; + return profile; + } catch (e) { + debugPrint('SolidOwnerProfileService._fetch($webId): $e'); + return _cache[webId] = SolidOwnerProfile.empty; + } finally { + _inflight.remove(webId); + } + } + + Future _safeFetchAvatar(String url) async { + try { + if (await checkResourceStatus(url) != ResourceStatus.exist) return null; + final ttl = await readPod(url, pathType: PathType.absoluteUrl); + return _extractAvatarBytes(ttl); + } catch (e) { + // Silently ignore: typical reasons are 403 (private profile) and + // 404 (no avatar set). Either way there is nothing to display. + + debugPrint('SolidOwnerProfileService._safeFetchAvatar($url): $e'); + return null; + } + } + + Future _safeFetchDisplayName(String url) async { + try { + if (await checkResourceStatus(url) != ResourceStatus.exist) return null; + final ttl = await readPod(url, pathType: PathType.absoluteUrl); + return _extractDisplayName(ttl); + } catch (e) { + debugPrint('SolidOwnerProfileService._safeFetchDisplayName($url): $e'); + return null; + } + } + + // Turtle parsing. + // + // Mirrors the extraction logic used in [SolidProfileService] so that owner + // resources written via that service can be read back out by other users. + + String? _extractDisplayName(String ttl) { + Map> map; + try { + map = turtleToTripleMap(ttl); + } catch (_) { + return null; + } + for (final pred in [ + FoafPredicate.name.value, + VcardPredicate.fn.value, + ]) { + for (final entry in map.values) { + final value = entry[pred]; + if (value == null) continue; + if (value is String && value.trim().isNotEmpty) return value; + if (value is Iterable && value.isNotEmpty) { + final first = value.first; + if (first is String && first.trim().isNotEmpty) return first; + } + } + } + return null; + } + + Uint8List? _extractAvatarBytes(String ttl) { + Map> map; + try { + map = turtleToTripleMap(ttl); + } catch (_) { + return null; + } + + String? findPhotoUri() { + for (final entry in map.values) { + final v = entry[VcardPredicate.hasPhoto.value]; + if (v is String && v.isNotEmpty) return v; + if (v is Iterable && v.isNotEmpty && v.first is String) { + return v.first as String; + } + } + return null; + } + + final photo = findPhotoUri(); + if (photo == null) return null; + + const marker = ';base64,'; + final idx = photo.indexOf(marker); + if (!photo.startsWith('data:') || idx < 0) return null; + + try { + return base64Decode(photo.substring(idx + marker.length)); + } catch (e) { + debugPrint('SolidOwnerProfileService._extractAvatarBytes: $e'); + return null; + } + } +} + +/// Computes a short text label (the "initials") to display when no avatar +/// image is available for a POD owner. +/// +/// Rules, applied in order: +/// 1. If [displayName] contains multiple whitespace-separated words, use +/// the first letter of the first word together with the first letter of +/// the last word (e.g. `Ada Byron Lovelace` → `AL`). +/// 2. If [displayName] is a single word, use its first two letters +/// (e.g. `cher` → `CH`). A one-character name returns that character. +/// 3. Otherwise fall back to the first two letters of the WebID's +/// username segment (e.g. `https://pods.example.au/john-doe/profile/card#me` +/// → `JO`). +/// 4. Returns an empty string when none of the above can be derived. The +/// caller is expected to fall back to a placeholder icon in that case. + +String computeOwnerInitials({String? displayName, String? webId}) { + final name = displayName?.trim() ?? ''; + if (name.isNotEmpty) { + final words = name + .split(RegExp(r'\s+')) + .where((w) => w.isNotEmpty) + .toList(growable: false); + if (words.length == 1) { + final w = words.first; + return w.length >= 2 ? w.substring(0, 2).toUpperCase() : w.toUpperCase(); + } else if (words.length > 1) { + return (words.first[0] + words.last[0]).toUpperCase(); + } + } + + if (webId != null && webId.isNotEmpty) { + final parts = WebIdParts.tryParse(webId); + final username = parts?.username ?? ''; + if (username.length >= 2) return username.substring(0, 2).toUpperCase(); + if (username.length == 1) return username.toUpperCase(); + } + + return ''; +} diff --git a/lib/src/widgets/solid_logout_dialog.dart b/lib/src/widgets/solid_logout_dialog.dart index 24434720..8edde2f5 100644 --- a/lib/src/widgets/solid_logout_dialog.dart +++ b/lib/src/widgets/solid_logout_dialog.dart @@ -1,3 +1,5 @@ +/// Solid Logout Dialogue. +/// /// Copyright (C) 2024-2025, Software Innovation Institute, ANU. /// /// Licensed under the MIT License (the "License"). @@ -32,6 +34,7 @@ import 'package:solidpod/solidpod.dart' show getAppNameVersion, getWebId, logoutPod; import 'package:solidui/src/services/solid_login_status_notifier.dart'; +import 'package:solidui/src/services/solid_owner_profile_service.dart'; import 'package:solidui/src/services/solid_profile_service.dart'; import 'package:solidui/src/utils/web_id_parser.dart'; @@ -80,6 +83,7 @@ class _LogoutDialogState extends State { onPressed: () async { if (await logoutPod()) { SolidProfileService.instance.clearCache(); + SolidOwnerProfileService.instance.clearCache(); solidLoginStatusNotifier.markLoggedOut(); widget.onLogoutSuccess?.call(); if (context.mounted) { diff --git a/lib/src/widgets/solid_owner_avatar.dart b/lib/src/widgets/solid_owner_avatar.dart new file mode 100644 index 00000000..19c1d298 --- /dev/null +++ b/lib/src/widgets/solid_owner_avatar.dart @@ -0,0 +1,281 @@ +/// Avatar widget that visually distinguishes between POD owners by showing +/// (in priority order) their profile picture, name initials, WebID initials, +/// or a placeholder icon. +/// +/// 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. +/// +/// Authors: Tony Chen + +library; + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show getWebId; + +import 'package:solidui/src/services/solid_owner_profile_service.dart'; +import 'package:solidui/src/services/solid_profile_notifier.dart'; + +/// Displays a small circular avatar identifying the owner of a resource +/// (typically the leading icon of a list item). +/// +/// The widget resolves what to show in the following priority order: +/// 1. The owner's profile picture, if one is available. +/// 2. Initials derived from the owner's display name (first + last +/// word initials, or first two letters of a single-word name). +/// 3. Initials taken from the first two letters of the WebID's username. +/// 4. [placeholderIcon] (defaults to a generic document icon) when none of +/// the above resolve. +/// +/// When [webId] matches the currently authenticated user the widget listens +/// to [solidProfileNotifier] so local edits (e.g. uploading a new avatar) +/// update the list view immediately. For other WebIDs the data is fetched +/// once via [SolidOwnerProfileService] and cached for the lifetime of the +/// process. + +class SolidOwnerAvatar extends StatefulWidget { + /// The WebID of the resource owner whose avatar should be displayed. + /// When `null` or empty the widget short-circuits to [placeholderIcon]. + + final String? webId; + + /// Diameter of the avatar circle. + + final double size; + + /// Icon used when no avatar or initials can be derived. Defaults to a + /// document glyph so the widget remains a drop-in replacement for the + /// previous static note icon used by NotePod's list views. + + final IconData placeholderIcon; + + const SolidOwnerAvatar({ + super.key, + required this.webId, + this.size = 40, + this.placeholderIcon = Icons.edit_document, + }); + + @override + State createState() => _SolidOwnerAvatarState(); +} + +class _SolidOwnerAvatarState extends State { + // The WebID of the currently authenticated user, resolved once when this + // widget is first built. Used to decide whether to listen to the local + // [solidProfileNotifier] or fetch a remote owner profile. + + String? _currentWebId; + bool _currentWebIdResolved = false; + + // Remote owner profile, populated only when [widget.webId] does not match + // the current user. + + SolidOwnerProfile? _remoteProfile; + + @override + void initState() { + super.initState(); + _resolveOwner(); + } + + @override + void didUpdateWidget(covariant SolidOwnerAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.webId != widget.webId) { + _remoteProfile = null; + _resolveOwner(); + } + } + + // Resolution. + // + // Determine whether the owner WebID is the current user (so we can use + // the live local profile notifier) or some other user (so we kick off a + // one-shot fetch via [SolidOwnerProfileService]). + + Future _resolveOwner() async { + final ownerWebId = widget.webId?.trim() ?? ''; + if (ownerWebId.isEmpty) { + if (!mounted) return; + setState(() { + _currentWebIdResolved = true; + _remoteProfile = SolidOwnerProfile.empty; + }); + return; + } + + if (!_currentWebIdResolved) { + try { + _currentWebId = await getWebId(); + } catch (_) { + _currentWebId = null; + } + if (!mounted) return; + setState(() => _currentWebIdResolved = true); + } + + // Owner is the current user: no remote fetch needed; we will read from + // [solidProfileNotifier] inside [build]. + + if (_currentWebId != null && _currentWebId == ownerWebId) { + return; + } + + // Owner is a different user: serve from cache when possible, otherwise + // kick off a single fetch via the shared service. + + final cached = SolidOwnerProfileService.instance.cachedProfile(ownerWebId); + if (cached != null) { + if (!mounted) return; + setState(() => _remoteProfile = cached); + return; + } + + try { + final profile = + await SolidOwnerProfileService.instance.fetchProfile(ownerWebId); + if (!mounted || widget.webId != ownerWebId) return; + setState(() => _remoteProfile = profile); + } catch (_) { + if (!mounted || widget.webId != ownerWebId) return; + setState(() => _remoteProfile = SolidOwnerProfile.empty); + } + } + + // Build. + + @override + Widget build(BuildContext context) { + final ownerWebId = widget.webId?.trim(); + if (ownerWebId == null || ownerWebId.isEmpty) { + return _buildPlaceholder(context); + } + + final isCurrentUser = _currentWebIdResolved && _currentWebId == ownerWebId; + if (isCurrentUser) { + return ListenableBuilder( + listenable: solidProfileNotifier, + builder: (context, _) { + return _buildContent( + context, + avatarBytes: solidProfileNotifier.avatarBytes, + displayName: solidProfileNotifier.displayName, + webId: ownerWebId, + ); + }, + ); + } + + // External owner: render from the resolved remote profile (possibly the + // shared service cache if a sibling widget has already fetched it). + + final cached = + SolidOwnerProfileService.instance.cachedProfile(ownerWebId) ?? + _remoteProfile; + return _buildContent( + context, + avatarBytes: cached?.avatarBytes, + displayName: cached?.displayName, + webId: ownerWebId, + ); + } + + Widget _buildContent( + BuildContext context, { + required Uint8List? avatarBytes, + required String? displayName, + required String webId, + }) { + if (avatarBytes != null && avatarBytes.isNotEmpty) { + return _buildAvatarImage(context, avatarBytes); + } + final initials = computeOwnerInitials( + displayName: displayName, + webId: webId, + ); + if (initials.isNotEmpty) { + return _buildInitials(context, initials); + } + return _buildPlaceholder(context); + } + + Widget _buildAvatarImage(BuildContext context, Uint8List bytes) { + final theme = Theme.of(context); + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primaryContainer, + image: DecorationImage( + image: MemoryImage(bytes), + fit: BoxFit.cover, + ), + ), + ); + } + + Widget _buildInitials(BuildContext context, String initials) { + final theme = Theme.of(context); + return Container( + width: widget.size, + height: widget.size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primaryContainer, + ), + child: Text( + initials, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: widget.size * 0.4, + fontWeight: FontWeight.w600, + height: 1.0, + ), + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: widget.size, + height: widget.size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primaryContainer, + ), + child: Icon( + widget.placeholderIcon, + size: widget.size * 0.55, + color: theme.colorScheme.onPrimaryContainer, + ), + ); + } +} From 5266a1eccf11f6e1f8edb7866c28e8588217a8f6 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 22 May 2026 19:57:20 +1000 Subject: [PATCH 2/5] Change colours if users have same two letters --- .../services/solid_owner_profile_service.dart | 113 ++++++++++++++++++ lib/src/widgets/solid_owner_avatar.dart | 52 ++++++-- 2 files changed, 154 insertions(+), 11 deletions(-) diff --git a/lib/src/services/solid_owner_profile_service.dart b/lib/src/services/solid_owner_profile_service.dart index 9dddde2a..81cf16dc 100644 --- a/lib/src/services/solid_owner_profile_service.dart +++ b/lib/src/services/solid_owner_profile_service.dart @@ -33,6 +33,7 @@ import 'dart:async'; import 'dart:convert' show base64Decode; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Color; import 'package:solidpod/solidpod.dart'; @@ -264,6 +265,118 @@ class SolidOwnerProfileService { } } +/// A pair of background + foreground colours used to render an owner's +/// initials or placeholder icon. +/// +/// Both colours together satisfy WCAG AA contrast (≥ 4.5:1) so labels remain +/// legible for low-vision users. + +@immutable +class SolidOwnerColourPair { + /// Fill colour for the avatar circle. + + final Color background; + + /// Recommended text/icon colour for legible contrast on [background]. + + final Color foreground; + + const SolidOwnerColourPair({ + required this.background, + required this.foreground, + }); +} + +/// Accessible colour palette used to distinguish between different POD owners +/// who happen to share the same initials (or first WebID letters). +/// +/// Each background is dark enough that white foreground text/icons satisfy +/// WCAG AA contrast (computed contrast ratios all exceed 4.5:1). The hues are +/// spread around the colour wheel so that adjacent entries remain +/// distinguishable under common forms of colour-vision deficiency +/// (deuteranopia, protanopia, tritanopia). The palette is intentionally +/// kept short (10 swatches) to maximise pairwise distinguishability — beyond +/// this many owners, collisions are unavoidable without sacrificing clarity. + +const List accessibleOwnerColourPalette = + [ + // Deep navy blue. + SolidOwnerColourPair( + background: Color(0xFF1F4E79), + foreground: Color(0xFFFFFFFF), + ), + // Burnt orange / rust. + SolidOwnerColourPair( + background: Color(0xFFB35900), + foreground: Color(0xFFFFFFFF), + ), + // Forest green. + SolidOwnerColourPair( + background: Color(0xFF2E7D32), + foreground: Color(0xFFFFFFFF), + ), + // Royal purple. + SolidOwnerColourPair( + background: Color(0xFF6A1B9A), + foreground: Color(0xFFFFFFFF), + ), + // Raspberry / dark pink. + SolidOwnerColourPair( + background: Color(0xFFC2185B), + foreground: Color(0xFFFFFFFF), + ), + // Deep teal. + SolidOwnerColourPair( + background: Color(0xFF00695C), + foreground: Color(0xFFFFFFFF), + ), + // Chocolate brown. + SolidOwnerColourPair( + background: Color(0xFF5D4037), + foreground: Color(0xFFFFFFFF), + ), + // Blue-grey slate. + SolidOwnerColourPair( + background: Color(0xFF455A64), + foreground: Color(0xFFFFFFFF), + ), + // Magenta. + SolidOwnerColourPair( + background: Color(0xFFAD1457), + foreground: Color(0xFFFFFFFF), + ), + // Cerulean blue. + SolidOwnerColourPair( + background: Color(0xFF01579B), + foreground: Color(0xFFFFFFFF), + ), +]; + +/// Returns a stable accent colour pair for [webId] drawn from +/// [accessibleOwnerColourPalette]. +/// +/// The mapping is deterministic: the same WebID always resolves to the same +/// entry, so an owner's avatar colour is consistent across rebuilds, sessions +/// and devices. Returns `null` when [webId] is `null` or empty so that +/// callers can fall back to a neutral theme colour for unknown owners. + +SolidOwnerColourPair? ownerColourPairFor(String? webId) { + if (webId == null) return null; + final trimmed = webId.trim(); + if (trimmed.isEmpty) return null; + + // Use a deterministic char-code based hash so the mapping is stable across + // platforms and runs, unlike `String.hashCode` which is documented as + // implementation-defined. + + var hash = 0; + for (final code in trimmed.codeUnits) { + hash = (hash * 31 + code) & 0x7FFFFFFF; + } + final index = hash % accessibleOwnerColourPalette.length; + return accessibleOwnerColourPalette[index]; +} + /// Computes a short text label (the "initials") to display when no avatar /// image is available for a POD owner. /// diff --git a/lib/src/widgets/solid_owner_avatar.dart b/lib/src/widgets/solid_owner_avatar.dart index 19c1d298..57d087a2 100644 --- a/lib/src/widgets/solid_owner_avatar.dart +++ b/lib/src/widgets/solid_owner_avatar.dart @@ -210,27 +210,42 @@ class _SolidOwnerAvatarState extends State { required String? displayName, required String webId, }) { + // Pick a stable accent colour for this owner so that two owners sharing + // the same initials (or WebID first letters) still appear visually + // different. The palette is curated for accessibility (WCAG AA contrast + // and colour-vision-deficiency friendly hues). + + final accent = ownerColourPairFor(webId); + if (avatarBytes != null && avatarBytes.isNotEmpty) { - return _buildAvatarImage(context, avatarBytes); + return _buildAvatarImage(context, avatarBytes, accent); } final initials = computeOwnerInitials( displayName: displayName, webId: webId, ); if (initials.isNotEmpty) { - return _buildInitials(context, initials); + return _buildInitials(context, initials, accent); } - return _buildPlaceholder(context); + return _buildPlaceholder(context, accent); } - Widget _buildAvatarImage(BuildContext context, Uint8List bytes) { + // The colour pair fed into each branch is `null` when no WebID is + // available, in which case the helpers fall back to the neutral theme + // colours used before owner-distinguishing colours were introduced. + + Widget _buildAvatarImage( + BuildContext context, + Uint8List bytes, + SolidOwnerColourPair? accent, + ) { final theme = Theme.of(context); return Container( width: widget.size, height: widget.size, decoration: BoxDecoration( shape: BoxShape.circle, - color: theme.colorScheme.primaryContainer, + color: accent?.background ?? theme.colorScheme.primaryContainer, image: DecorationImage( image: MemoryImage(bytes), fit: BoxFit.cover, @@ -239,20 +254,28 @@ class _SolidOwnerAvatarState extends State { ); } - Widget _buildInitials(BuildContext context, String initials) { + Widget _buildInitials( + BuildContext context, + String initials, + SolidOwnerColourPair? accent, + ) { final theme = Theme.of(context); + final background = + accent?.background ?? theme.colorScheme.primaryContainer; + final foreground = + accent?.foreground ?? theme.colorScheme.onPrimaryContainer; return Container( width: widget.size, height: widget.size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, - color: theme.colorScheme.primaryContainer, + color: background, ), child: Text( initials, style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, + color: foreground, fontSize: widget.size * 0.4, fontWeight: FontWeight.w600, height: 1.0, @@ -261,20 +284,27 @@ class _SolidOwnerAvatarState extends State { ); } - Widget _buildPlaceholder(BuildContext context) { + Widget _buildPlaceholder( + BuildContext context, [ + SolidOwnerColourPair? accent, + ]) { final theme = Theme.of(context); + final background = + accent?.background ?? theme.colorScheme.primaryContainer; + final foreground = + accent?.foreground ?? theme.colorScheme.onPrimaryContainer; return Container( width: widget.size, height: widget.size, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, - color: theme.colorScheme.primaryContainer, + color: background, ), child: Icon( widget.placeholderIcon, size: widget.size * 0.55, - color: theme.colorScheme.onPrimaryContainer, + color: foreground, ), ); } From fe3fcb844985c0e18607f012c0ab65654587a9a5 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 22 May 2026 19:58:14 +1000 Subject: [PATCH 3/5] Lint --- lib/src/widgets/solid_owner_avatar.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/widgets/solid_owner_avatar.dart b/lib/src/widgets/solid_owner_avatar.dart index 57d087a2..a89831e7 100644 --- a/lib/src/widgets/solid_owner_avatar.dart +++ b/lib/src/widgets/solid_owner_avatar.dart @@ -260,8 +260,7 @@ class _SolidOwnerAvatarState extends State { SolidOwnerColourPair? accent, ) { final theme = Theme.of(context); - final background = - accent?.background ?? theme.colorScheme.primaryContainer; + final background = accent?.background ?? theme.colorScheme.primaryContainer; final foreground = accent?.foreground ?? theme.colorScheme.onPrimaryContainer; return Container( @@ -289,8 +288,7 @@ class _SolidOwnerAvatarState extends State { SolidOwnerColourPair? accent, ]) { final theme = Theme.of(context); - final background = - accent?.background ?? theme.colorScheme.primaryContainer; + final background = accent?.background ?? theme.colorScheme.primaryContainer; final foreground = accent?.foreground ?? theme.colorScheme.onPrimaryContainer; return Container( From 5cc5173e41dfdd0be9c94823b076bdde777db71b Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 22 May 2026 20:00:09 +1000 Subject: [PATCH 4/5] Update .lycheeignore --- .lycheeignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.lycheeignore b/.lycheeignore index 8a4f74ed..4531deff 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -63,6 +63,7 @@ https://graph.facebook.com/ https://login.microsoftonline.com/ https://securetoken.google.com/ -# File URL used in comments +# URL used in comments file:///home/runner/work/solidui/solidui/lib/src/utils/scheme +https://pods.example.au/john-doe/profile/card#me From d4456540e85b25df4ad6b80d34ffa44e38092d10 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 22 May 2026 20:13:43 +1000 Subject: [PATCH 5/5] Fix some minor issues --- lib/src/widgets/solid_preferences_button_order.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/src/widgets/solid_preferences_button_order.dart b/lib/src/widgets/solid_preferences_button_order.dart index 3eea97ba..dd62003c 100644 --- a/lib/src/widgets/solid_preferences_button_order.dart +++ b/lib/src/widgets/solid_preferences_button_order.dart @@ -105,11 +105,7 @@ class SolidPreferencesButtonOrderSection extends StatelessWidget { shrinkWrap: true, buildDefaultDragHandles: false, itemCount: appBarActions.length, - // onReorderItem (post v3.41.0-0.0.pre) replaces the legacy - // onReorder. The framework now hands us the already-adjusted - // target index, so callers no longer need - // `if (oldIndex < newIndex) newIndex--`. - onReorderItem: onReorder, + onReorder: onReorder, itemBuilder: (context, index) { final action = appBarActions[index]; return _SolidPreferencesButtonItem(