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

Issue:
ui: Follow `user_settings.twenty_four_hour_time`
When we show timestamps, for example on messages in the message list, we should format them according to the user's setting:

- `user_settings.twenty_four_hour_time` true: "13:52"
- `user_settings.twenty_four_hour_time` false: "1:52 PM"

This is something we did in the zulip-mobile legacy app.

In addition to the timestamps on messages in the message list (which is the most visible and most important place), this should apply to everywhere else that we show a time of day to the user. To find other such places, try `git grep DateFormat`.

### References

- [Help center doc](https://zulip.com/help/change-the-time-format)
- `UserSettings.twentyFourHourTime` in lib/api/model/initial_snapshot.dart
- `UserSettingsUpdateEvent` in lib/api/model/events.dart



Discussion hints:
I would like to work on this issue if it has not been assigned to anyone else.
Please see the Zulip project's guide to claiming an issue: https://zulip.readthedocs.io/en/latest/contributing/contributing.html#in-other-zulip-repositories

In particular:

> In your comment, describe what part of the code you’re modifying and how you plan to approach the problem, based on what you learned in steps 1–4 [above](https://zulip.readthedocs.io/en/latest/contributing/contributing.html#picking-an-issue-to-work-on).
Hi @chrisbobbe,  

I would like to take up this issue.  

I have started working on it and have achieved some successful results.  
You can review my changes here: [Compare Changes](https://github.com/zulip/zulip-flutter/compare/main...Gaurav-Kushwaha-1225:zulip-flutter:Follow_UserSettings_Time).  
Please permit me to continue working on this issue.  

Additionally, I have taken screenshots after changing the `twenty_four_hour_time` preference in my account via the web app:  

| 12-Hour Format | 24-Hour Format |  
| -------------- | -------------- |  
| ![12-Hour](https://github.com/user-attachments/assets/a79a2511-0468-46b9-ba21-19b46966859f) | ![24-Hour](https://github.com/user-attachments/assets/bb02bc4c-3a19-4851-b8e7-e66a62246c92) |  

Currently, some checks are failing. I will look into them once the issue is assigned to me.  

Looking forward to your feedback! 🚀  
Requested in launch feedback: https://chat.zulip.org/#narrow/stream/48-mobile/topic/12-hour.20time.20format/near/2216815

Pull request: Follow user's time format setting (12 or 24 hour)
Fixes #1015.

cc @alya for screenshots below.

Test command: flutter test test/api/route/settings_test.dart test/model/store_test.dart test/widgets/content_test.dart test/widgets/lightbox_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index bb3d7245e0..6070616387 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -175,6 +175,7 @@ class UserSettingsUpdateEvent extends Event {
     final value = json['value'];
     switch (UserSettingName.fromRawString(json['property'] as String)) {
       case UserSettingName.twentyFourHourTime:
+        return TwentyFourHourTimeMode.fromApiValue(value as bool?);
       case UserSettingName.displayEmojiReactionUsers:
         return value as bool;
       case UserSettingName.emojiset:
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 45b97745d6..d9734582c6 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -259,7 +259,12 @@ class RecentDmConversation {
 /// in <https://zulip.com/api/register-queue>.
 @JsonSerializable(fieldRename: FieldRename.snake, createFieldMap: true)
 class UserSettings {
-  bool twentyFourHourTime;
+  @JsonKey(
+    fromJson: TwentyFourHourTimeMode.fromApiValue,
+    toJson: TwentyFourHourTimeMode.staticToJson,
+  )
+  TwentyFourHourTimeMode twentyFourHourTime;
+
   bool? displayEmojiReactionUsers; // TODO(server-6)
   Emojiset emojiset;
   bool presenceEnabled;
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index c8b50a7b78..32af6a1867 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -230,7 +230,9 @@ Map<String, dynamic> _$RecentDmConversationToJson(
 };
 
 UserSettings _$UserSettingsFromJson(Map<String, dynamic> json) => UserSettings(
-  twentyFourHourTime: json['twenty_four_hour_time'] as bool,
+  twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue(
+    json['twenty_four_hour_time'] as bool?,
+  ),
   displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool?,
   emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']),
   presenceEnabled: json['presence_enabled'] as bool,
@@ -245,7 +247,9 @@ const _$UserSettingsFieldMap = <String, String>{
 
 Map<String, dynamic> _$UserSettingsToJson(UserSettings instance) =>
     <String, dynamic>{
-      'twenty_four_hour_time': instance.twentyFourHourTime,
+      'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson(
+        instance.twentyFourHourTime,
+      ),
       'display_emoji_reaction_users': instance.displayEmojiReactionUsers,
       'emojiset': instance.emojiset,
       'presence_enabled': instance.presenceEnabled,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 4302a82c11..1009418d08 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -298,6 +298,35 @@ enum UserSettingName {
   String toJson() => _$UserSettingNameEnumMap[this]!;
 }
 
+/// A value from [UserSettings.twentyFourHourTime].
+enum TwentyFourHourTimeMode {
+  twelveHour(apiValue: false),
+  twentyFourHour(apiValue: true),
+
+  /// The locale's default format (12-hour for en_US, 24-hour for fr_FR, etc.).
+  // TODO(#1727) actually follow this
+  // Not sent by current servers, but planned when most client installs accept it:
+  //   https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696
+  // TODO(server-future) Write down what server N starts sending null;
+  //   adjust the comment; leave a TODO(server-N) to delete the comment
+  localeDefault(apiValue: null),
+  ;
+
+  const TwentyFourHourTimeMode({required this.apiValue});
+
+  final bool? apiValue;
+
+  static bool? staticToJson(TwentyFourHourTimeMode instance) => instance.apiValue;
+
+  bool? toJson() => TwentyFourHourTimeMode.staticToJson(this);
+
+  static TwentyFourHourTimeMode fromApiValue(bool? value) => switch (value) {
+    false => twelveHour,
+    true => twentyFourHour,
+    null => localeDefault,
+  };
+}
+
 /// As in [UserSettings.emojiset].
 @JsonEnum(fieldRename: FieldRename.kebab, alwaysCreate: true)
 enum Emojiset {
diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart
index 929a199d0b..4e98140d76 100644
--- a/lib/api/route/settings.dart
+++ b/lib/api/route/settings.dart
@@ -9,12 +9,20 @@ Future<void> updateSettings(ApiConnection connection, {
   for (final entry in newSettings.entries) {
     final name = entry.key;
     final valueRaw = entry.value;
-    final value = switch (name) {
-      UserSettingName.twentyFourHourTime => valueRaw as bool,
-      UserSettingName.displayEmojiReactionUsers => valueRaw as bool,
-      UserSettingName.emojiset => RawParameter((valueRaw as Emojiset).toJson()),
-      UserSettingName.presenceEnabled => valueRaw as bool,
-    };
+    final Object? value;
+    switch (name) {
+      case UserSettingName.twentyFourHourTime:
+        final mode = (valueRaw as TwentyFourHourTimeMode);
+        // TODO(server-future) allow localeDefault for servers that support it
+        assert(mode != TwentyFourHourTimeMode.localeDefault);
+        value = mode.toJson();
+      case UserSettingName.displayEmojiReactionUsers:
+        value = valueRaw as bool;
+      case UserSettingName.emojiset:
+        value = RawParameter((valueRaw as Emojiset).toJson());
+      case UserSettingName.presenceEnabled:
+        value = valueRaw as bool;
+    }
     params[name.toJson()] = value;
   }
 
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 03ff13022a..a24550c406 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -707,7 +707,7 @@ class PerAccountStore extends PerAccountStoreBase with
         }
         switch (event.property!) {
           case UserSettingName.twentyFourHourTime:
-            userSettings.twentyFourHourTime        = event.value as bool;
+            userSettings.twentyFourHourTime        = event.value as TwentyFourHourTimeMode;
           case UserSettingName.displayEmojiReactionUsers:
             userSettings.displayEmojiReactionUsers = event.value as bool;
           case UserSettingName.emojiset:
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 5d6dfa5084..5851222a1c 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1304,13 +1304,26 @@ class GlobalTime extends StatelessWidget {
   final GlobalTimeNode node;
   final TextStyle ambientTextStyle;
 
-  static final _dateFormat = intl.DateFormat('EEE, MMM d, y, h:mm a'); // TODO(i18n): localize date
+  static final _format12 =
+    intl.DateFormat('EEE, MMM d, y').addPattern('h:mm aa', ', ');
+  static final _format24 =
+    intl.DateFormat('EEE, MMM d, y').addPattern('Hm', ', ');
+  static final _formatLocaleDefault =
+    intl.DateFormat('EEE, MMM d, y').addPattern('jm', ', ');
 
   @override
   Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
+    final twentyFourHourTimeMode = store.userSettings.twentyFourHourTime;
     // Design taken from css for `.rendered_markdown & time` in web,
     //   see zulip:web/styles/rendered_markdown.css .
-    final text = _dateFormat.format(node.datetime.toLocal());
+    // TODO(i18n): localize; see plan with ffi in #45
+    final format = switch (twentyFourHourTimeMode) {
+      TwentyFourHourTimeMode.twelveHour => _format12,
+      TwentyFourHourTimeMode.twentyFourHour => _format24,
+      TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault,
+    };
+    final text = format.format(node.datetime.toLocal());
     final contentTheme = ContentTheme.of(context);
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 2),
diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart
index bf11522036..0a1fe78feb 100644
--- a/lib/widgets/lightbox.dart
+++ b/lib/widgets/lightbox.dart
@@ -1,7 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:flutter/services.dart';
-import 'package:intl/intl.dart';
 import 'package:video_player/video_player.dart';
 
 import '../api/core.dart';
@@ -12,6 +11,7 @@ import '../model/binding.dart';
 import 'actions.dart';
 import 'content.dart';
 import 'dialog.dart';
+import 'message_list.dart';
 import 'page.dart';
 import 'store.dart';
 import 'user.dart';
@@ -167,6 +167,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {
 
   @override
   Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
     final store = PerAccountStoreWidget.of(context);
     final themeData = Theme.of(context);
 
@@ -176,11 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {
 
     PreferredSizeWidget? appBar;
     if (_headerFooterVisible) {
-      // TODO(#45): Format with e.g. "Yesterday at 4:47 PM"
-      final timestampText = DateFormat
-        .yMMMd(/* TODO(#278): Pass selected language here, I think? */)
-        .add_Hms()
-        .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000));
+      final timestampText = MessageTimestampStyle.lightbox
+        .format(widget.message.timestamp,
+          now: DateTime.now(),
+          twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+          zulipLocalizations: zulipLocalizations);
 
       // We use plain [AppBar] instead of [ZulipAppBar], even though this page
       // has a [PerAccountStore], because:
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index eccdf8c6a6..e3b1d69d6e 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart' hide TextDirection;
 
 import '../api/model/model.dart';
 import '../generated/l10n/zulip_localizations.dart';
+import '../model/binding.dart';
 import '../model/database.dart';
 import '../model/message.dart';
 import '../model/message_list.dart';
@@ -1840,8 +1841,14 @@ class DateText extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    final store = PerAccountStoreWidget.of(context);
     final messageListTheme = MessageListTheme.of(context);
     final zulipLocalizations = ZulipLocalizations.of(context);
+    final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format(
+      timestamp,
+      now: ZulipBinding.instance.utcNow().toLocal(),
+      twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+      zulipLocalizations: zulipLocalizations)!;
     return Text(
       style: TextStyle(
         color: messageListTheme.labelTime,
@@ -1851,46 +1858,7 @@ class DateText extends StatelessWidget {
         //   https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps
         fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')],
       ),
-      formatHeaderDate(
-        zulipLocalizations,
-        DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
-        now: DateTime.now()));
-  }
-}
-
-@visibleForTesting
-String formatHeaderDate(
-  ZulipLocalizations zulipLocalizations,
-  DateTime dateTime, {
-  required DateTime now,
-}) {
-  assert(!dateTime.isUtc && !now.isUtc,
-    '`dateTime` and `now` need to be in local time.');
-
-  if (dateTime.year == now.year &&
-      dateTime.month == now.month &&
-      dateTime.day == now.day) {
-    return zulipLocalizations.today;
-  }
-
-  final yesterday = now
-    .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
-    .add(const Duration(days: -1));
-  if (dateTime.year == yesterday.year &&
-      dateTime.month == yesterday.month &&
-      dateTime.day == yesterday.day) {
-    return zulipLocalizations.yesterday;
-  }
-
-  // If it is Dec 1 and you see a label that says `Dec 2`
-  // it could be misinterpreted as Dec 2 of the previous
-  // year. For times in the future, those still on the
-  // current day will show as today (handled above) and
-  // any dates beyond that show up with the year.
-  if (dateTime.year == now.year && dateTime.isBefore(now)) {
-    return DateFormat.MMMd().format(dateTime);
-  } else {
-    return DateFormat.yMMMd().format(dateTime);
+      formattedTimestamp);
   }
 }
 
@@ -1915,12 +1883,17 @@ class SenderRow extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
     final store = PerAccountStoreWidget.of(context);
     final messageListTheme = MessageListTheme.of(context);
     final designVariables = DesignVariables.of(context);
 
     final sender = store.getUser(message.senderId);
-    final timestamp = timestampStyle.format(message.timestamp);
+    final timestamp = timestampStyle
+      .format(message.timestamp,
+        now: DateTime.now(),
+        twentyFourHourTimeMode: store.userSettings.twentyFourHourTime,
+        zulipLocalizations: zulipLocalizations);
 
     final showAsMuted = _showAsMuted(context, store);
 
@@ -1982,11 +1955,14 @@ class SenderRow extends StatelessWidget {
   }
 }
 
-// TODO centralize on this for wherever we show message timestamps
 enum MessageTimestampStyle {
   none,
+  dateOnlyRelative,
   timeOnly,
 
+  // TODO(#45): E.g. "Yesterday at 4:47 PM"; see details in #45
+  lightbox,
+
   /// The longest format, with full date and time as numbers, not "Today"/etc.
   ///
   /// For UI contexts focused just on the one message,
@@ -2001,19 +1977,88 @@ enum MessageTimestampStyle {
   full,
   ;
 
-  static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US');
-  static final _fullFormat = DateFormat.yMMMd().add_jm();
+  static String _formatDateOnlyRelative(
+    DateTime dateTime, {
+    required DateTime now,
+    required ZulipLocalizations zulipLocalizations,
+  }) {
+    assert(!dateTime.isUtc && !now.isUtc,
+      '`dateTime` and `now` need to be in local time.');
+
+    if (dateTime.year == now.year &&
+        dateTime.month == now.month &&
+        dateTime.day == now.day) {
+      return zulipLocalizations.today;
+    }
+
+    final yesterday = now
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0)
+      .add(const Duration(days: -1));
+    if (dateTime.year == yesterday.year &&
+        dateTime.month == yesterday.month &&
+        dateTime.day == yesterday.day) {
+      return zulipLocalizations.yesterday;
+    }
+
+    // If it is Dec 1 and you see a label that says `Dec 2`
+    // it could be misinterpreted as Dec 2 of the previous
+    // year. For times in the future, those still on the
+    // current day will show as today (handled above) and
+    // any dates beyond that show up with the year.
+    if (dateTime.year == now.year && dateTime.isBefore(now)) {
+      return DateFormat.MMMd().format(dateTime);
+    } else {
+      return DateFormat.yMMMd().format(dateTime);
+    }
+  }
+
+  static final _timeFormat12 =                       DateFormat('h:mm aa');
+  static final _timeFormat24 =                       DateFormat('Hm');
+  static final _timeFormatLocaleDefault =            DateFormat('jm');
+  static final _timeFormat12WithSeconds =            DateFormat('h:mm:ss aa');
+  static final _timeFormat24WithSeconds =            DateFormat('Hms');
+  static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms');
+
+  static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) {
+    TwentyFourHourTimeMode.twelveHour => _timeFormat12,
+    TwentyFourHourTimeMode.twentyFourHour => _timeFormat24,
+    TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault,
+  };
+
+  static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) {
+    TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds,
+    TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds,
+    TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds,
+  };
 
   /// Format a [Message.timestamp] for this mode.
   // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi)
-  String? format(int messageTimestamp) {
+  String? format(
+    int messageTimestamp, {
+    required DateTime now,
+    required ZulipLocalizations zulipLocalizations,
+    required TwentyFourHourTimeMode twentyFourHourTimeMode,
+  }) {
     final asDateTime =
       DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp);
 
     switch (this) {
       case none:     return null;
-      case timeOnly: return _timeOnlyFormat.format(asDateTime);
-      case full: return _fullFormat.format(asDateTime);
+      case dateOnlyRelative:
+        return _formatDateOnlyRelative(asDateTime,
+          now: now, zulipLocalizations: zulipLocalizations);
+      case timeOnly:
+        return _resolveTimeFormat(twentyFourHourTimeMode).format(asDateTime);
+      case lightbox:
+        return DateFormat
+          .yMMMd()
+          .addPattern(_resolveTimeFormatWithSeconds(twentyFourHourTimeMode).pattern)
+          .format(asDateTime);
+      case full:
+        return DateFormat
+          .yMMMd()
+          .addPattern(_resolveTimeFormat(twentyFourHourTimeMode).pattern)
+          .format(asDateTime);
     }
   }
 }


Test patch:
diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart
index d2808ece02..8b31caa646 100644
--- a/test/api/route/settings_test.dart
+++ b/test/api/route/settings_test.dart
@@ -17,8 +17,8 @@ void main() {
       for (final name in UserSettingName.values) {
         switch (name) {
           case UserSettingName.twentyFourHourTime:
-            newSettings[name] = true;
-            expectedBodyFields['twenty_four_hour_time'] = 'true';
+            newSettings[name] = TwentyFourHourTimeMode.twelveHour;
+            expectedBodyFields['twenty_four_hour_time'] = 'false';
           case UserSettingName.displayEmojiReactionUsers:
             newSettings[name] = false;
             expectedBodyFields['display_emoji_reaction_users'] = 'false';
@@ -38,4 +38,16 @@ void main() {
         ..bodyFields.deepEquals(expectedBodyFields);
     });
   });
+
+  test('TwentyFourHourTime.localeDefault', () async {
+    return FakeApiConnection.with_((connection) async {
+      connection.prepare(json: {});
+
+      // TODO(server-future) instead, check for twenty_four_hour_time: null
+      //   (could be an error-prone part of the JSONification)
+      check(() => updateSettings(connection,
+        newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault})
+      ).throws<AssertionError>();
+    });
+  });
 }
diff --git a/test/example_data.dart b/test/example_data.dart
index c33c5fdfdc..b44108eb21 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -1262,7 +1262,7 @@ InitialSnapshot initialSnapshot({
     streams: streams ?? [], // TODO add streams to default
     userStatuses: userStatuses ?? {},
     userSettings: userSettings ?? UserSettings(
-      twentyFourHourTime: false,
+      twentyFourHourTime: TwentyFourHourTimeMode.twelveHour,
       displayEmojiReactionUsers: true,
       emojiset: Emojiset.google,
       presenceEnabled: true,
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 240f2cbffd..c90b45a25f 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -698,14 +698,16 @@ void main() {
       await preparePoll();
 
       // Pick some arbitrary event and check it gets processed on the store.
-      check(store.userSettings.twentyFourHourTime).isFalse();
+      check(store.userSettings.twentyFourHourTime)
+        .equals(TwentyFourHourTimeMode.twelveHour);
       connection.prepare(json: GetEventsResult(events: [
         UserSettingsUpdateEvent(id: 2,
           property: UserSettingName.twentyFourHourTime, value: true),
       ], queueId: null).toJson());
       updateMachine.debugAdvanceLoop();
       async.elapse(Duration.zero);
-      check(store.userSettings.twentyFourHourTime).isTrue();
+      check(store.userSettings.twentyFourHourTime)
+        .equals(TwentyFourHourTimeMode.twentyFourHour);
     }));
 
     void checkReload(FutureOr<void> Function() prepareError, {
@@ -735,14 +737,16 @@ void main() {
         // The new UpdateMachine updates the new store.
         updateMachine.debugPauseLoop();
         updateMachine.poll();
-        check(store.userSettings.twentyFourHourTime).isFalse();
+        check(store.userSettings.twentyFourHourTime)
+          .equals(TwentyFourHourTimeMode.twelveHour);
         connection.prepare(json: GetEventsResult(events: [
           UserSettingsUpdateEvent(id: 2,
             property: UserSettingName.twentyFourHourTime, value: true),
         ], queueId: null).toJson());
         updateMachine.debugAdvanceLoop();
         async.elapse(Duration.zero);
-        check(store.userSettings.twentyFourHourTime).isTrue();
+        check(store.userSettings.twentyFourHourTime)
+          .equals(TwentyFourHourTimeMode.twentyFourHour);
       });
     }
 
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 0075f50b11..2b7eb45180 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -7,6 +7,8 @@ import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/api/core.dart';
+import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/content.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/settings.dart';
@@ -118,9 +120,13 @@ Widget plainContent(String html) {
 Future<void> prepareContent(WidgetTester tester, Widget child, {
   List<NavigatorObserver> navObservers = const [],
   bool wrapWithPerAccountStoreWidget = false,
+  InitialSnapshot? initialSnapshot,
 }) async {
   if (wrapWithPerAccountStoreWidget) {
-    await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+    initialSnapshot ??= eg.initialSnapshot();
+    await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+  } else {
+    assert(initialSnapshot == null);
   }
 
   addTearDown(testBinding.reset);
@@ -598,10 +604,12 @@ void main() {
   Future<void> checkFontSizeRatio(WidgetTester tester, {
     required String targetHtml,
     required TargetFontSizeFinder targetFontSizeFinder,
+    bool wrapWithPerAccountStoreWidget = false,
   }) async {
-    await prepareContent(tester, plainContent(
-      '<h1>header-plain $targetHtml</h1>\n'
-      '<p>paragraph-plain $targetHtml</p>'));
+    await prepareContent(tester, wrapWithPerAccountStoreWidget: wrapWithPerAccountStoreWidget,
+      plainContent(
+        '<h1>header-plain $targetHtml</h1>\n'
+        '<p>paragraph-plain $targetHtml</p>'));
 
     final headerRootSpan = tester.renderObject<RenderParagraph>(find.textContaining('header')).text;
     final headerPlainStyle = mergedStyleOfSubstring(headerRootSpan, 'header-plain ');
@@ -1071,16 +1079,52 @@ void main() {
     // the timezone of the environment running these tests. Accept here a wide
     // range of times. See comments in "show dates" test in
     // `test/widgets/message_list_test.dart`.
-    final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$');
+    final renderedTextRegexp = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d(?: [AP]M)?$');
+    final renderedTextRegexpTwelveHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d [AP]M$');
+    final renderedTextRegexpTwentyFourHour = RegExp(r'^(Tue, Jan 30|Wed, Jan 31), 2024, \d+:\d\d$');
+
+    Future<void> prepare(
+      WidgetTester tester,
+      [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault]
+    ) async {
+      final initialSnapshot = eg.initialSnapshot()
+        ..userSettings.twentyFourHourTime = twentyFourHourTimeMode;
+      await prepareContent(tester,
+        // We use the self-account's time-format setting.
+        wrapWithPerAccountStoreWidget: true,
+        initialSnapshot: initialSnapshot,
+        plainContent('<p>$timeSpanHtml</p>'));
+    }
 
     testWidgets('smoke', (tester) async {
-      await prepareContent(tester, plainContent('<p>$timeSpanHtml</p>'));
+      await prepare(tester);
       tester.widget(find.textContaining(renderedTextRegexp));
     });
 
+    testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.twelveHour);
+      check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne();
+    });
+
+    testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.twentyFourHour);
+      check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne();
+    });
+
+    testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async {
+      await prepare(tester, TwentyFourHourTimeMode.localeDefault);
+      // This expectation holds as long as we're always formatting in en_US,
+      // the default locale, which uses the twelve-hour format.
+      // TODO(#1727) follow the actual locale; test with different locales
+      check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne();
+    });
+
     void testIconAndTextSameColor(String description, String html) {
       testWidgets('clock icon and text are the same color: $description', (tester) async {
-        await prepareContent(tester, plainContent(html));
+        await prepareContent(tester,
+          // We use the self-account's time-format setting.
+          wrapWithPerAccountStoreWidget: true,
+          plainContent(html));
 
         final icon = tester.widget<Icon>(
           find.descendant(of: find.byType(GlobalTime),
@@ -1100,6 +1144,8 @@ void main() {
     group('maintains font-size ratio with surrounding text', () {
       Future<void> doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async {
         await checkFontSizeRatio(tester,
+          // We use the self-account's time-format setting.
+          wrapWithPerAccountStoreWidget: true,
           targetHtml: '<time datetime="2024-01-30T17:33:00Z">2024-01-30T17:33:00Z</time>',
           targetFontSizeFinder: (rootSpan) {
             late final double result;
diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart
index fc013173e7..a7f6240852 100644
--- a/test/widgets/lightbox_test.dart
+++ b/test/widgets/lightbox_test.dart
@@ -382,12 +382,12 @@ void main() {
       await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]);
       check(store.getUser(sender.userId)).isNotNull();
 
-      checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 11:12:24 PM');
 
       await store.handleEvent(RealmUserUpdateEvent(id: 1,
         userId: sender.userId, fullName: 'New name'));
       await tester.pump();
-      checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 11:12:24 PM');
 
       debugNetworkImageHttpClientProvider = null;
     });
@@ -400,7 +400,7 @@ void main() {
       await setupPage(tester, message: message, thumbnailUrl: null, users: []);
       check(store.getUser(sender.userId)).isNull();
 
-      checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24');
+      checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 11:12:24 PM');
 
       debugNetworkImageHttpClientProvider = null;
     });
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index a7c4b14dbf..3636861a2e 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
 import 'dart:io';
 
 import 'package:checks/checks.dart';
+import 'package:clock/clock.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -1637,26 +1638,80 @@ void main() {
     });
   });
 
-  group('formatHeaderDate', () {
-    final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
-    final now = DateTime.parse("2023-01-10 12:00");
-    final testCases = [
-      ("2023-01-10 12:00", zulipLocalizations.today),
-      ("2023-01-10 00:00", zulipLocalizations.today),
-      ("2023-01-10 23:59", zulipLocalizations.today),
-      ("2023-01-09 23:59", zulipLocalizations.yesterday),
-      ("2023-01-09 00:00", zulipLocalizations.yesterday),
-      ("2023-01-08 00:00", "Jan 8"),
-      ("2022-12-31 00:00", "Dec 31, 2022"),
-      // Future times
-      ("2023-01-10 19:00", zulipLocalizations.today),
-      ("2023-01-11 00:00", "Jan 11, 2023"),
-    ];
-    for (final (dateTime, expected) in testCases) {
-      test('$dateTime returns $expected', () {
-        check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now))
-          .equals(expected);
-      });
+  group('MessageTimestampStyle', () {
+    void doTests(
+      MessageTimestampStyle style,
+      List<(
+        String timestampStr,
+        String? expectedTwelveHour,
+        String? expectedTwentyFourHour,
+      )> cases, {
+      DateTime? now,
+    }) {
+      now ??= DateTime.parse("2023-01-10 12:00");
+      for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) {
+        for (final mode in TwentyFourHourTimeMode.values) {
+          final expected = switch (mode) {
+            TwentyFourHourTimeMode.twelveHour => expectedTwelveHour,
+            TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour,
+            // This expectation will hold as long as we're always using the
+            // default locale, en_US, which uses the twelve-hour format.
+            // TODO(#1727) test with other locales
+            TwentyFourHourTimeMode.localeDefault => expectedTwelveHour,
+          };
+
+          test('${style.name} in ${mode.name}: $timestampStr returns $expected', () {
+            addTearDown(testBinding.reset);
+            final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+
+            withClock(Clock.fixed(now!), () {
+              final timestamp = DateTime.parse(timestampStr)
+                .millisecondsSinceEpoch ~/ 1000;
+              final result = style.format(
+                timestamp,
+                now: testBinding.utcNow().toLocal(),
+                twentyFourHourTimeMode: mode,
+                zulipLocalizations: zulipLocalizations);
+              check(result).equals(expected);
+            });
+          });
+        }
+      }
+    }
+
+    for (final style in MessageTimestampStyle.values) {
+      switch (style) {
+        case MessageTimestampStyle.none:
+          doTests(style, [('2023-01-10 12:00', null, null)]);
+        case MessageTimestampStyle.dateOnlyRelative:
+          final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+          doTests(style,
+            now: DateTime.parse("2023-01-10 12:00"),
+            [
+              ("2023-01-10 12:00", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-10 00:00", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-10 23:59", zulipLocalizations.today,     zulipLocalizations.today),
+              ("2023-01-09 23:59", zulipLocalizations.yesterday, zulipLocalizations.yesterday),
+              ("2023-01-09 00:00", zulipLocalizations.yesterday, zulipLocalizations.yesterday),
+              ("2023-01-08 00:00", "Jan 8", "Jan 8"),
+              ("2022-12-31 00:00", "Dec 31, 2022", "Dec 31, 2022"),
+              // Future times
+              ("2023-01-10 19:00", zulipLocalizations.today, zulipLocalizations.today),
+              ("2023-01-11 00:00", "Jan 11, 2023", "Jan 11, 2023"),
+            ]);
+        case MessageTimestampStyle.timeOnly:
+          doTests(style, [('2023-01-10 12:00', '12:00 PM', '12:00')]);
+        case MessageTimestampStyle.lightbox:
+          doTests(style,
+            [('2023-01-10 12:00',
+              'Jan 10, 2023 12:00:00 PM',
+              'Jan 10, 2023 12:00:00')]);
+        case MessageTimestampStyle.full:
+          doTests(style,
+            [('2023-01-10 12:00',
+              'Jan 10, 2023 12:00 PM',
+              'Jan 10, 2023 12:00')]);
+      }
     }
   });