Repository: zulip/zulip-flutter (language: dart)

Issue:
Request avatars of the appropriate size
With #195 fixed with PR #287, we now have a user-profile screen! 🎉

I noticed that on many profiles on CZO, the avatar is more blurry than on the corresponding screen in `zulip-mobile`:

| zulip-flutter | zulip-mobile |
| --- | --- | 
| ![image](https://github.com/zulip/zulip-flutter/assets/22248748/66896560-9b27-4f0d-813d-2164e7aaafca) | ![image](https://github.com/zulip/zulip-flutter/assets/22248748/644603ff-c376-45b4-b50e-dc0f0d1839bc) |

This is because we don't yet have special logic for requesting a larger image when we need one. This issue is for adding such logic.

In `zulip-mobile`, we made an `AvatarURL` class with a `get` method that gives you a URL for an image of the specified size in physical pixels (or at least the best possible approximation). `AvatarURL` has [three subclasses](https://github.com/zulip/zulip-mobile/blob/09754f0a5d7408fd490a631339eced3cf77a28e0/src/utils/avatar.js) that each implement `get` very differently.

On CZO in `#api documentation`, I've [summarized](https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/avatar_url.3A.20Request.20specific.20size/near/1636546) zulip-mobile's logic for determining a URL for any given size in physical pixels. I'm hoping the API doc can start specifying that logic, and we can use the new documentation for zulip-flutter's implementation.

To convert a logical-pixel value to a physical-pixel value, we'll want to multiply by the device pixel ratio. As in zulip-mobile:
```js
avatarUrl.get(PixelRatio.getPixelSizeForLayoutSize(size))
```


I think a solution will likely have lots of interactions with #254 and #255, so the best approach might be to consider these three issues together.

Related: #254
Related: #255


Discussion hints:
An observant user noticed the blurriness and reported it in chat today: https://chat.zulip.org/#narrow/stream/48-mobile/topic/flutter.3A.20blurry.20profile.20picture/near/1735359
@gnprice I'm currently working on this feature. Finished with most of the task using gravatar's. I would like to know which formatter you are using because as I save my code most of the file gets re-formatted causing several changes in the git history. It would be really helpful if you linked the contributing guidelines as well. 
@saisumith770 Cool — assigned you the issue, and I'll look at your PR in the next few days.

We don't use an auto-formatter, so you'll want to just tell your editor not to do that. Let's discuss in `#mobile-team` on chat.zulip.org ([thread](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/turning.20off.20reformat-on-save/near/1745058)).

For contributing guidelines, this repo's README file has information on running tests and making changes. We also follow the same basic style of clean commit history as the Zulip project in general, which is described here:
https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html


Pull request: Fix avatar blurriness in profile screen
## Description
Updated the avatar URL used in the profile screen to request a higher-resolution image to reduce blurriness.
Fixes #301 

## Outcome
| Before             |  After |
:-------------------------:|:-------------------------:
![image](https://github.com/zulip/zulip-flutter/assets/67259068/b5e84b15-bb8c-421a-bbf4-7a7f97c1057d)  |  ![image](https://github.com/zulip/zulip-flutter/assets/67259068/38ee3f22-0c3f-4b09-827d-d9debf521e82)
![image](https://github.com/zulip/zulip-flutter/assets/67259068/eef42b86-f413-43e3-876e-a7d60c83deb4) |  ![image](https://github.com/zulip/zulip-flutter/assets/67259068/c258e9a0-70aa-4e93-adc1-385a18873561)


Test command: flutter test test/model/avatar_url_test.dart test/widgets/content_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/model/avatar_url.dart b/lib/model/avatar_url.dart
new file mode 100644
index 0000000000..8177a4e8c0
--- /dev/null
+++ b/lib/model/avatar_url.dart
@@ -0,0 +1,50 @@
+/// The size threshold above which is "medium" size for an avatar.
+///
+/// This is DEFAULT_AVATAR_SIZE in zerver/lib/upload.py.
+const defaultUploadSizePx = 100;
+
+abstract class AvatarUrl {
+  factory AvatarUrl.fromUserData({required Uri resolvedUrl}) {
+    // TODO(#255): handle computing gravatars
+    // TODO(#254): handle computing fallback avatar
+    if (resolvedUrl.toString().startsWith(GravatarUrl.origin)) {
+      return GravatarUrl(resolvedUrl: resolvedUrl);
+    } else {
+      return UploadedAvatarUrl(resolvedUrl: resolvedUrl);
+    }
+  }
+
+  Uri get(int sizePhysicalPx);
+}
+
+class GravatarUrl implements AvatarUrl {
+  GravatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
+
+  static String origin = 'https://secure.gravatar.com';
+
+  Uri standardUrl;
+
+  @override
+  Uri get(int sizePhysicalPx) {
+    return standardUrl.replace(queryParameters: {
+      ...standardUrl.queryParameters,
+      's': sizePhysicalPx.toString(),
+    });
+  }
+}
+
+class UploadedAvatarUrl implements AvatarUrl {
+  UploadedAvatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
+
+  Uri standardUrl;
+
+  @override
+  Uri get(int sizePhysicalPx) {
+    if (sizePhysicalPx > defaultUploadSizePx) {
+      return standardUrl.replace(
+        path: standardUrl.path.replaceFirst(RegExp(r'(?:\.png)?$'), "-medium.png"));
+    }
+
+    return standardUrl;
+  }
+}
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index c5e2682ad2..24794464b4 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -7,6 +7,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
 
 import '../api/core.dart';
 import '../api/model/model.dart';
+import '../model/avatar_url.dart';
 import '../model/binding.dart';
 import '../model/content.dart';
 import '../model/internal_link.dart';
@@ -1027,7 +1028,7 @@ class Avatar extends StatelessWidget {
     return AvatarShape(
       size: size,
       borderRadius: borderRadius,
-      child: AvatarImage(userId: userId));
+      child: AvatarImage(userId: userId, size: size));
   }
 }
 
@@ -1040,9 +1041,11 @@ class AvatarImage extends StatelessWidget {
   const AvatarImage({
     super.key,
     required this.userId,
+    required this.size,
   });
 
   final int userId;
+  final double size;
 
   @override
   Widget build(BuildContext context) {
@@ -1057,9 +1060,19 @@ class AvatarImage extends StatelessWidget {
       null          => null, // TODO(#255): handle computing gravatars
       var avatarUrl => store.tryResolveUrl(avatarUrl),
     };
-    return (resolvedUrl == null)
-      ? const SizedBox.shrink()
-      : RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium, fit: BoxFit.cover);
+
+    if (resolvedUrl == null) {
+      return const SizedBox.shrink();
+    }
+
+    final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl);
+    final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil();
+
+    return RealmContentNetworkImage(
+      avatarUrl.get(physicalSize),
+      filterQuality: FilterQuality.medium,
+      fit: BoxFit.cover,
+    );
   }
 }
 
diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart
index aafdc8c42f..27bf2a7560 100644
--- a/lib/widgets/recent_dm_conversations.dart
+++ b/lib/widgets/recent_dm_conversations.dart
@@ -84,6 +84,8 @@ class RecentDmConversationsItem extends StatelessWidget {
   final DmNarrow narrow;
   final int unreadCount;
 
+  static const double _avatarSize = 32;
+
   @override
   Widget build(BuildContext context) {
     final store = PerAccountStoreWidget.of(context);
@@ -94,14 +96,14 @@ class RecentDmConversationsItem extends StatelessWidget {
     switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage]
       case []:
         title = selfUser.fullName;
-        avatar = AvatarImage(userId: selfUser.userId);
+        avatar = AvatarImage(userId: selfUser.userId, size: _avatarSize);
       case [var otherUserId]:
         // TODO(#296) actually don't show this row if the user is muted?
         //   (should we offer a "spam folder" style summary screen of recent
         //   1:1 DM conversations from muted users?)
         final otherUser = store.users[otherUserId];
         title = otherUser?.fullName ?? '(unknown user)';
-        avatar = AvatarImage(userId: otherUserId);
+        avatar = AvatarImage(userId: otherUserId, size: _avatarSize);
       default:
         // TODO(i18n): List formatting, like you can do in JavaScript:
         //   new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
@@ -122,7 +124,7 @@ class RecentDmConversationsItem extends StatelessWidget {
         child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
           child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
             Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8),
-              child: AvatarShape(size: 32, borderRadius: 3, child: avatar)),
+              child: AvatarShape(size: _avatarSize, borderRadius: 3, child: avatar)),
             const SizedBox(width: 8),
             Expanded(child: Padding(
               padding: const EdgeInsets.symmetric(vertical: 4),


Test patch:
diff --git a/test/model/avatar_url_test.dart b/test/model/avatar_url_test.dart
new file mode 100644
index 0000000000..f12d3a60f9
--- /dev/null
+++ b/test/model/avatar_url_test.dart
@@ -0,0 +1,34 @@
+import 'package:checks/checks.dart';
+import 'package:test/scaffolding.dart';
+import 'package:zulip/model/avatar_url.dart';
+
+void main() {
+  const defaultSize = 30;
+  const largeSize = 120;
+
+  group('GravatarUrl', () {
+    test('gravatar url', () {
+      final url = '${GravatarUrl.origin}/avatar/1234';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+
+      check(avatarUrl.get(defaultSize).toString()).equals('$url?s=30');
+    });
+  });
+
+  group('UploadedAvatarUrl', () {
+    test('png image', () {
+      const url = 'https://zulip.example/image.png';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+
+      check(avatarUrl.get(defaultSize).toString()).equals(url);
+    });
+
+    test('png image, larger size', () {
+      const url = 'https://zulip.example/image.png';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+      final expectedUrl = url.replaceAll('.png', '-medium.png');
+
+      check(avatarUrl.get(largeSize).toString()).equals(expectedUrl);
+    });
+  });
+}
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index c485f585a7..5976171eec 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -538,7 +538,7 @@ void main() {
   group('AvatarImage', () {
     late PerAccountStore store;
 
-    Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl) async {
+    Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async {
       addTearDown(testBinding.reset);
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
       store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
@@ -548,7 +548,7 @@ void main() {
       prepareBoringImageHttpClient();
       await tester.pumpWidget(GlobalStoreWidget(
         child: PerAccountStoreWidget(accountId: eg.selfAccount.id,
-          child: AvatarImage(userId: user.userId))));
+          child: AvatarImage(userId: user.userId, size: size ?? 30))));
       await tester.pump();
       await tester.pump();
       tester.widget(find.byType(AvatarImage));
@@ -571,6 +571,26 @@ void main() {
       debugNetworkImageHttpClientProvider = null;
     });
 
+   testWidgets('absolute URL, larger size', (tester) async {
+      tester.view.devicePixelRatio = 2.5;
+      addTearDown(tester.view.resetDevicePixelRatio);
+
+      const avatarUrl = 'https://example/avatar.png';
+      check(await actualUrl(tester, avatarUrl, 50)).isNotNull()
+        .asString.equals(avatarUrl.replaceAll('.png', '-medium.png'));
+      debugNetworkImageHttpClientProvider = null;
+    });
+
+    testWidgets('relative URL, larger size', (tester) async {
+      tester.view.devicePixelRatio = 2.5;
+      addTearDown(tester.view.resetDevicePixelRatio);
+
+      const avatarUrl = '/avatar.png';
+      check(await actualUrl(tester, avatarUrl, 50))
+        .equals(store.tryResolveUrl('/avatar-medium.png')!);
+      debugNetworkImageHttpClientProvider = null;
+    });
+
     testWidgets('smoke with invalid URL', (tester) async {
       const avatarUrl = '::not a URL::';
       check(await actualUrl(tester, avatarUrl)).isNull();