From ed529c73eeb86690800ca6788cbec3055469976e Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 19:48:36 +0000 Subject: [PATCH 1/6] app: local initials avatar for reviews (no network dependency) --- .../app_detail/widgets/review_avatar.dart | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/lib/pages/apps/app_detail/widgets/review_avatar.dart diff --git a/app/lib/pages/apps/app_detail/widgets/review_avatar.dart b/app/lib/pages/apps/app_detail/widgets/review_avatar.dart new file mode 100644 index 00000000000..b267c749c4c --- /dev/null +++ b/app/lib/pages/apps/app_detail/widgets/review_avatar.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Initials avatar rendered entirely locally — no network dependency. +/// +/// Review reviewer photos are not stored anywhere, so avatars were previously +/// generated from a third-party service. When that service is unreachable the +/// image request hangs (never errors), leaving a blank placeholder circle. This +/// draws a deterministic colored circle with the reviewer's initial instead. +class ReviewAvatar extends StatelessWidget { + final String seed; + final String username; + final double size; + final Color? backgroundColor; + final Color? foregroundColor; + + const ReviewAvatar({ + super.key, + required this.seed, + required this.username, + this.size = 40, + this.backgroundColor, + this.foregroundColor, + }); + + static const List _palette = [ + Color(0xFF6C5CE7), + Color(0xFF00B894), + Color(0xFF0984E3), + Color(0xFFE17055), + Color(0xFFD63031), + Color(0xFF00CEC9), + Color(0xFFE84393), + Color(0xFFFDCB6E), + ]; + + String get _initial => username.isNotEmpty ? username[0].toUpperCase() : 'A'; + + Color get _background { + if (backgroundColor != null) return backgroundColor!; + final key = seed.isNotEmpty ? seed : username; + return _palette[key.hashCode.abs() % _palette.length]; + } + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration(color: _background, shape: BoxShape.circle), + child: Text( + _initial, + style: TextStyle(color: foregroundColor ?? Colors.white, fontSize: size * 0.45, fontWeight: FontWeight.w600), + ), + ); + } +} From 61e07fd7979f207033817b954f8a8220d1261c17 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 19:48:44 +0000 Subject: [PATCH 2/6] app: render review list avatars locally, drop dead avatar service --- .../apps/app_detail/reviews_list_page.dart | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/app/lib/pages/apps/app_detail/reviews_list_page.dart b/app/lib/pages/apps/app_detail/reviews_list_page.dart index f8874a682b5..d65b245dab0 100644 --- a/app/lib/pages/apps/app_detail/reviews_list_page.dart +++ b/app/lib/pages/apps/app_detail/reviews_list_page.dart @@ -8,6 +8,7 @@ import 'package:omi/backend/http/api/apps.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; import 'package:omi/pages/apps/app_detail/app_detail.dart'; +import 'package:omi/pages/apps/app_detail/widgets/review_avatar.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/widgets/extensions/string.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -152,13 +153,6 @@ class _ReviewsListPageState extends State { ); } - String _getAvatarUrl(String seed, String? username) { - if (username != null && username.isNotEmpty) { - return 'https://avatar.iran.liara.run/username?username=${Uri.encodeComponent(username)}'; - } - return 'https://avatar.iran.liara.run/public/${seed.hashCode % 100}'; - } - Map _getRatingDistribution(List reviews) { final distribution = {5: 0, 4: 0, 3: 0, 2: 0, 1: 0}; for (final review in reviews) { @@ -311,33 +305,7 @@ class _ReviewsListPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Avatar - ClipOval( - child: Container( - width: 40, - height: 40, - color: Colors.grey.shade800, - child: Image.network( - _getAvatarUrl(avatarSeed, review.username), - width: 40, - height: 40, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - final initial = review.username.isNotEmpty ? review.username[0].toUpperCase() : 'A'; - return Container( - width: 40, - height: 40, - decoration: BoxDecoration(color: Colors.grey.shade800, shape: BoxShape.circle), - child: Center( - child: Text( - initial, - style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), - ), - ); - }, - ), - ), - ), + ReviewAvatar(seed: avatarSeed, username: review.username, size: 40), const SizedBox(width: 12), // Name, date, and stars Expanded( From f44005ec83ab92d6492b8229c1a440f63cbe0ea2 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 19:48:45 +0000 Subject: [PATCH 3/6] app: render review preview avatars locally, drop dead avatar service --- app/lib/pages/apps/app_detail/app_detail.dart | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/app/lib/pages/apps/app_detail/app_detail.dart b/app/lib/pages/apps/app_detail/app_detail.dart index f7c944f2ec5..73921085ba1 100644 --- a/app/lib/pages/apps/app_detail/app_detail.dart +++ b/app/lib/pages/apps/app_detail/app_detail.dart @@ -18,6 +18,7 @@ import 'package:omi/backend/http/api/apps.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/apps/app_detail/reviews_list_page.dart'; +import 'package:omi/pages/apps/app_detail/widgets/review_avatar.dart'; import 'package:omi/pages/apps/app_home_web_page.dart'; import 'package:omi/pages/apps/markdown_viewer.dart'; import 'package:omi/pages/apps/providers/add_app_provider.dart'; @@ -688,7 +689,7 @@ class _AppDetailPageState extends State { await Share.share( 'https://h.omi.me/apps/${app.id}', - subject: app.name.isEmpty ? null : app.name, + subject: app.name, sharePositionOrigin: sharePositionOrigin, ); }, @@ -1659,16 +1660,6 @@ class _RecentReviewsSectionState extends State { super.dispose(); } - String _getAvatarUrl(String seed, String? username) { - // Using Avatar Placeholder API for random avatars - // If username is available, use username-based avatar for consistency - if (username != null && username.isNotEmpty) { - return 'https://avatar.iran.liara.run/username?username=${Uri.encodeComponent(username)}'; - } - // Otherwise use a seeded random avatar - return 'https://avatar.iran.liara.run/public/${seed.hashCode % 100}'; - } - Future _submitReview() async { if (editRating == 0) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.pleaseSelectRating))); @@ -1913,40 +1904,13 @@ class _RecentReviewsSectionState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Random Avatar - ClipOval( - child: Container( - width: 36, - height: 36, - color: Colors.grey.shade800, - child: Image.network( - _getAvatarUrl(avatarSeed, review.username), - width: 36, - height: 36, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - final initial = review.username.isNotEmpty ? review.username[0].toUpperCase() : 'A'; - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: isUserReview ? Colors.deepPurple.withOpacity(0.2) : Colors.grey.shade800, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - initial, - style: TextStyle( - color: isUserReview ? Colors.deepPurple : Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ); - }, - ), - ), + // Avatar + ReviewAvatar( + seed: avatarSeed, + username: review.username, + size: 36, + backgroundColor: isUserReview ? Colors.deepPurple.withOpacity(0.2) : null, + foregroundColor: isUserReview ? Colors.deepPurple : null, ), const SizedBox(width: 12), // Name, date, and stars From 2bf0ff2d85ccbec0079b2908fbf35b468b33be76 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 19:48:45 +0000 Subject: [PATCH 4/6] app: test ReviewAvatar renders initials with no network call --- app/test/widgets/review_avatar_test.dart | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/test/widgets/review_avatar_test.dart diff --git a/app/test/widgets/review_avatar_test.dart b/app/test/widgets/review_avatar_test.dart new file mode 100644 index 00000000000..736336d0ed3 --- /dev/null +++ b/app/test/widgets/review_avatar_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/pages/apps/app_detail/widgets/review_avatar.dart'; + +Widget _wrap(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), +); + +void main() { + group('ReviewAvatar', () { + testWidgets('renders the uppercased first initial of the username', (tester) async { + await tester.pumpWidget(_wrap(const ReviewAvatar(seed: 'uid_1', username: 'jane'))); + expect(find.text('J'), findsOneWidget); + }); + + testWidgets('falls back to "A" when the username is empty', (tester) async { + await tester.pumpWidget(_wrap(const ReviewAvatar(seed: 'uid_1', username: ''))); + expect(find.text('A'), findsOneWidget); + }); + + testWidgets('makes no network calls (no Image widget in the tree)', (tester) async { + // Regression: avatars previously came from a third-party service that, when + // unreachable, hung forever and left a blank circle. The local avatar must + // never reach the network. + await tester.pumpWidget(_wrap(const ReviewAvatar(seed: 'uid_1', username: 'jane'))); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('same seed yields a stable background color', (tester) async { + Color bgOf(Finder f) => ((tester.widget(f).decoration) as BoxDecoration).color!; + + await tester.pumpWidget(_wrap(const ReviewAvatar(seed: 'uid_stable', username: 'jane'))); + final first = bgOf(find.byType(Container)); + + await tester.pumpWidget(_wrap(const ReviewAvatar(seed: 'uid_stable', username: 'jane'))); + final second = bgOf(find.byType(Container)); + + expect(first, second); + }); + + testWidgets('honors explicit background and foreground colors', (tester) async { + await tester.pumpWidget( + _wrap( + const ReviewAvatar( + seed: 'uid_1', + username: 'jane', + backgroundColor: Color(0xFF112233), + foregroundColor: Color(0xFF445566), + ), + ), + ); + final container = tester.widget(find.byType(Container)); + expect((container.decoration as BoxDecoration).color, const Color(0xFF112233)); + expect(tester.widget(find.text('J')).style!.color, const Color(0xFF445566)); + }); + }); +} From 9e84bc963ebd63d165c728b53966456faedff4a8 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 20:08:06 +0000 Subject: [PATCH 5/6] app: stable avatar color hash + luminance-based initial contrast --- .../app_detail/widgets/review_avatar.dart | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/lib/pages/apps/app_detail/widgets/review_avatar.dart b/app/lib/pages/apps/app_detail/widgets/review_avatar.dart index b267c749c4c..918ba8c8875 100644 --- a/app/lib/pages/apps/app_detail/widgets/review_avatar.dart +++ b/app/lib/pages/apps/app_detail/widgets/review_avatar.dart @@ -35,10 +35,29 @@ class ReviewAvatar extends StatelessWidget { String get _initial => username.isNotEmpty ? username[0].toUpperCase() : 'A'; + // Deterministic FNV-1a hash. Dart's String.hashCode is seeded per run + // (hash-flood mitigation), so the same reviewer could otherwise get a + // different color on every app launch. + static int _stableHash(String s) { + var hash = 0x811c9dc5; + for (final unit in s.codeUnits) { + hash ^= unit; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; + } + Color get _background { if (backgroundColor != null) return backgroundColor!; final key = seed.isNotEmpty ? seed : username; - return _palette[key.hashCode.abs() % _palette.length]; + return _palette[_stableHash(key) % _palette.length]; + } + + // White initials wash out on the light palette entries; pick a legible + // foreground from the background's luminance when no override is given. + Color get _foreground { + if (foregroundColor != null) return foregroundColor!; + return _background.computeLuminance() > 0.5 ? const Color(0xFF1F1F25) : Colors.white; } @override @@ -50,7 +69,7 @@ class ReviewAvatar extends StatelessWidget { decoration: BoxDecoration(color: _background, shape: BoxShape.circle), child: Text( _initial, - style: TextStyle(color: foregroundColor ?? Colors.white, fontSize: size * 0.45, fontWeight: FontWeight.w600), + style: TextStyle(color: _foreground, fontSize: size * 0.45, fontWeight: FontWeight.w600), ), ); } From 8b18a0cdaacfaf734052f64f5dff64441f612473 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sun, 21 Jun 2026 20:08:06 +0000 Subject: [PATCH 6/6] app: test ReviewAvatar contrast on light/dark backgrounds --- app/test/widgets/review_avatar_test.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/test/widgets/review_avatar_test.dart b/app/test/widgets/review_avatar_test.dart index 736336d0ed3..5ee496cdd90 100644 --- a/app/test/widgets/review_avatar_test.dart +++ b/app/test/widgets/review_avatar_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:omi/pages/apps/app_detail/widgets/review_avatar.dart'; Widget _wrap(Widget child) => MaterialApp( - home: Scaffold(body: Center(child: child)), -); + home: Scaffold(body: Center(child: child)), + ); void main() { group('ReviewAvatar', () { @@ -38,6 +38,21 @@ void main() { expect(first, second); }); + testWidgets('uses dark initials on a light background for contrast', (tester) async { + // No foreground override + a light background must not render white initials. + await tester.pumpWidget( + _wrap(const ReviewAvatar(seed: 'uid_1', username: 'jane', backgroundColor: Color(0xFFFDCB6E))), + ); + expect(tester.widget(find.text('J')).style!.color, const Color(0xFF1F1F25)); + }); + + testWidgets('uses white initials on a dark background for contrast', (tester) async { + await tester.pumpWidget( + _wrap(const ReviewAvatar(seed: 'uid_1', username: 'jane', backgroundColor: Color(0xFF1F1F25))), + ); + expect(tester.widget(find.text('J')).style!.color, Colors.white); + }); + testWidgets('honors explicit background and foreground colors', (tester) async { await tester.pumpWidget( _wrap(