Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 9 additions & 45 deletions app/lib/pages/apps/app_detail/app_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -688,7 +689,7 @@ class _AppDetailPageState extends State<AppDetailPage> {

await Share.share(
'https://h.omi.me/apps/${app.id}',
subject: app.name.isEmpty ? null : app.name,
subject: app.name,
sharePositionOrigin: sharePositionOrigin,
);
},
Expand Down Expand Up @@ -1659,16 +1660,6 @@ class _RecentReviewsSectionState extends State<RecentReviewsSection> {
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<void> _submitReview() async {
if (editRating == 0) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.pleaseSelectRating)));
Expand Down Expand Up @@ -1913,40 +1904,13 @@ class _RecentReviewsSectionState extends State<RecentReviewsSection> {
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
Expand Down
36 changes: 2 additions & 34 deletions app/lib/pages/apps/app_detail/reviews_list_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,13 +153,6 @@ class _ReviewsListPageState extends State<ReviewsListPage> {
);
}

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<int, int> _getRatingDistribution(List<AppReview> reviews) {
final distribution = {5: 0, 4: 0, 3: 0, 2: 0, 1: 0};
for (final review in reviews) {
Expand Down Expand Up @@ -311,33 +305,7 @@ class _ReviewsListPageState extends State<ReviewsListPage> {
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(
Expand Down
76 changes: 76 additions & 0 deletions app/lib/pages/apps/app_detail/widgets/review_avatar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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<Color> _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';

// 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[_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
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: _foreground, fontSize: size * 0.45, fontWeight: FontWeight.w600),
),
);
}
}
72 changes: 72 additions & 0 deletions app/test/widgets/review_avatar_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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<Container>(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('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<Text>(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<Text>(find.text('J')).style!.color, Colors.white);
});

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<Container>(find.byType(Container));
expect((container.decoration as BoxDecoration).color, const Color(0xFF112233));
expect(tester.widget<Text>(find.text('J')).style!.color, const Color(0xFF445566));
});
});
}
Loading