From f9ec516e3d3779153c41966d7041054fbf7ab2cc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Sat, 12 Jul 2025 23:12:18 -0400 Subject: [PATCH 01/14] lightbox [nfc]: Go through MessageTimestampStyle for timestamp --- lib/widgets/lightbox.dart | 9 +++------ lib/widgets/message_list.dart | 5 +++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index bf11522036..3abff9674a 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'; @@ -176,11 +176,8 @@ 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); // 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..73bb9da7a8 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1987,6 +1987,9 @@ enum MessageTimestampStyle { none, 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, @@ -2002,6 +2005,7 @@ enum MessageTimestampStyle { ; static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); + static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. @@ -2013,6 +2017,7 @@ enum MessageTimestampStyle { switch (this) { case none: return null; case timeOnly: return _timeOnlyFormat.format(asDateTime); + case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } } From 55b95d2e766df686b58e6f65fe65ea9a6bb5f6b0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 12:33:46 -0400 Subject: [PATCH 02/14] msglist [nfc]: Add two params to MessageTimestampStyle.format, to use soon --- lib/widgets/lightbox.dart | 5 +++-- lib/widgets/message_list.dart | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 3abff9674a..98e9aae4e5 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.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,8 +177,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - final timestampText = MessageTimestampStyle.lightbox - .format(widget.message.timestamp); + final timestampText = MessageTimestampStyle.lightbox.format( + widget.message.timestamp, now: DateTime.now(), 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 73bb9da7a8..dddfa3399a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1915,12 +1915,14 @@ 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(), zulipLocalizations: zulipLocalizations); final showAsMuted = _showAsMuted(context, store); @@ -2010,7 +2012,11 @@ enum MessageTimestampStyle { /// 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, + }) { final asDateTime = DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); From 7704fd8131bfa18caeb17a72233b3c0b4a3ecebd Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 15:46:28 -0700 Subject: [PATCH 03/14] msglist [nfc]: Use ZulipBinding.instance.utcNow().toLocal() in DateText Thanks Komyyy for this idea, which I took from PR #1363. Co-authored-by: Komyyy --- lib/widgets/message_list.dart | 3 ++- test/widgets/message_list_test.dart | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index dddfa3399a..1bf60891bf 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'; @@ -1854,7 +1855,7 @@ class DateText extends StatelessWidget { formatHeaderDate( zulipLocalizations, DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); + now: ZulipBinding.instance.utcNow().toLocal())); } } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index a7c4b14dbf..0c0518667d 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'; @@ -1654,8 +1655,16 @@ void main() { ]; for (final (dateTime, expected) in testCases) { test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); + addTearDown(testBinding.reset); + + withClock(Clock.fixed(now), () { + check(formatHeaderDate( + zulipLocalizations, + DateTime.parse(dateTime), + now: testBinding.utcNow().toLocal(), + )) + .equals(expected); + }); }); } }); From 3703baa2d6abe19c8ade814797185f05e2b13411 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 12:30:56 -0400 Subject: [PATCH 04/14] msglist [nfc]: Finish centralizing on MessageTimestampStyle --- lib/widgets/message_list.dart | 84 +++++++++++++++-------------- test/widgets/message_list_test.dart | 60 +++++++++++---------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1bf60891bf..0bd5a8eb65 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1843,6 +1843,10 @@ class DateText extends StatelessWidget { Widget build(BuildContext context) { final messageListTheme = MessageListTheme.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + final formattedTimestamp = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: ZulipBinding.instance.utcNow().toLocal(), + zulipLocalizations: zulipLocalizations)!; return Text( style: TextStyle( color: messageListTheme.labelTime, @@ -1852,46 +1856,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: ZulipBinding.instance.utcNow().toLocal())); - } -} - -@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); } } @@ -1985,9 +1950,9 @@ 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 @@ -2007,6 +1972,40 @@ enum MessageTimestampStyle { full, ; + 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 _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); @@ -2023,6 +2022,9 @@ enum MessageTimestampStyle { switch (this) { case none: return null; + case dateOnlyRelative: + return _formatDateOnlyRelative(asDateTime, + now: now, zulipLocalizations: zulipLocalizations); case timeOnly: return _timeOnlyFormat.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 0c0518667d..6468eb6266 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1638,35 +1638,39 @@ 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', () { - addTearDown(testBinding.reset); - - withClock(Clock.fixed(now), () { - check(formatHeaderDate( - zulipLocalizations, - DateTime.parse(dateTime), - now: testBinding.utcNow().toLocal(), - )) - .equals(expected); + group('MessageTimestampStyle', () { + group('dateOnlyRelative', () { + 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', () { + addTearDown(testBinding.reset); + + withClock(Clock.fixed(now), () { + final timestamp = DateTime.parse(dateTime).millisecondsSinceEpoch ~/ 1000; + final result = MessageTimestampStyle.dateOnlyRelative.format( + timestamp, + now: testBinding.utcNow().toLocal(), + zulipLocalizations: zulipLocalizations); + check(result).equals(expected); + }); }); - }); - } + } + }); + + // TODO others }); group('MessageWithPossibleSender', () { From 0a6b2f75fb98fe5e2198bcc67fd0059f348800b7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 23 Jul 2025 14:52:09 -0700 Subject: [PATCH 05/14] msglist test: Test all `MessageTimestampStyle`s, not just dateOnlyRelative --- test/widgets/message_list_test.dart | 64 +++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 6468eb6266..569fbc1978 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1639,28 +1639,21 @@ void main() { }); group('MessageTimestampStyle', () { - group('dateOnlyRelative', () { - 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', () { + void doTests( + MessageTimestampStyle style, + List<(String timestampStr, String? expected)> cases, { + DateTime? now, + }) { + now ??= DateTime.parse("2023-01-10 12:00"); + for (final (timestampStr, expected) in cases) { + test('${style.name}: $timestampStr returns $expected', () { addTearDown(testBinding.reset); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - withClock(Clock.fixed(now), () { - final timestamp = DateTime.parse(dateTime).millisecondsSinceEpoch ~/ 1000; - final result = MessageTimestampStyle.dateOnlyRelative.format( + withClock(Clock.fixed(now!), () { + final timestamp = DateTime.parse(timestampStr) + .millisecondsSinceEpoch ~/ 1000; + final result = style.format( timestamp, now: testBinding.utcNow().toLocal(), zulipLocalizations: zulipLocalizations); @@ -1668,9 +1661,36 @@ void main() { }); }); } - }); + } - // TODO others + for (final style in MessageTimestampStyle.values) { + switch (style) { + case MessageTimestampStyle.none: + doTests(style, [('2023-01-10 12:00', 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), + ("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"), + ]); + case MessageTimestampStyle.timeOnly: + doTests(style, [('2023-01-10 12:00', '12:00 PM')]); + case MessageTimestampStyle.lightbox: + doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00:00')]); + case MessageTimestampStyle.full: + doTests(style, [('2023-01-10 12:00', 'Jan 10, 2023 12:00 PM')]); + } + } }); group('MessageWithPossibleSender', () { From 4a77fbd14b5b4bce7d574e76172f7fc071785097 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 14 Jul 2025 17:17:29 -0400 Subject: [PATCH 06/14] api: Start accepting null for user_settings.twenty_four_hour_time Servers can't yet start sending null without breaking clients. Releasing this will start lowering the number of client installs that would break, and eventually there will be few enough that the breakage is acceptable; see discussion (same link as in comment): https://chat.zulip.org/#narrow/channel/378-api-design/topic/.60user_settings.2Etwenty_four_hour_time.60/near/2220696 --- lib/api/model/events.dart | 1 + lib/api/model/initial_snapshot.dart | 7 ++++++- lib/api/model/initial_snapshot.g.dart | 8 ++++++-- lib/api/model/model.dart | 29 +++++++++++++++++++++++++++ lib/api/route/settings.dart | 20 ++++++++++++------ lib/model/store.dart | 2 +- test/api/route/settings_test.dart | 16 +++++++++++++-- test/example_data.dart | 2 +- test/model/store_test.dart | 12 +++++++---- 9 files changed, 80 insertions(+), 17 deletions(-) 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 . @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 _$RecentDmConversationToJson( }; UserSettings _$UserSettingsFromJson(Map 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 = { Map _$UserSettingsToJson(UserSettings instance) => { - '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 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/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(); + }); + }); } 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 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); }); } From ddc9adeca55ed02021e194afed21fc391d2de671 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 17:17:39 -0400 Subject: [PATCH 07/14] model [nfc]: Pass TwentyFourHourTimeMode to MessageTimestampStyle.format --- lib/widgets/lightbox.dart | 7 +++++-- lib/widgets/message_list.dart | 10 ++++++++-- test/widgets/message_list_test.dart | 1 + 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 98e9aae4e5..0a1fe78feb 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -177,8 +177,11 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { PreferredSizeWidget? appBar; if (_headerFooterVisible) { - final timestampText = MessageTimestampStyle.lightbox.format( - widget.message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); + 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 0bd5a8eb65..9af5889deb 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1841,11 +1841,13 @@ 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( @@ -1887,8 +1889,11 @@ class SenderRow extends StatelessWidget { final designVariables = DesignVariables.of(context); final sender = store.getUser(message.senderId); - final timestamp = timestampStyle.format( - message.timestamp, now: DateTime.now(), zulipLocalizations: zulipLocalizations); + final timestamp = timestampStyle + .format(message.timestamp, + now: DateTime.now(), + twentyFourHourTimeMode: store.userSettings.twentyFourHourTime, + zulipLocalizations: zulipLocalizations); final showAsMuted = _showAsMuted(context, store); @@ -2016,6 +2021,7 @@ enum MessageTimestampStyle { int messageTimestamp, { required DateTime now, required ZulipLocalizations zulipLocalizations, + required TwentyFourHourTimeMode twentyFourHourTimeMode, }) { final asDateTime = DateTime.fromMillisecondsSinceEpoch(1000 * messageTimestamp); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 569fbc1978..43aa4ac79e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1656,6 +1656,7 @@ void main() { final result = style.format( timestamp, now: testBinding.utcNow().toLocal(), + twentyFourHourTimeMode: TwentyFourHourTimeMode.localeDefault, zulipLocalizations: zulipLocalizations); check(result).equals(expected); }); From 1d1f426d7901a52346293a40ff71bce4feb60f9b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:52:36 -0400 Subject: [PATCH 08/14] msglist [nfc]: Remove a hard-coded 'en_US' which is the default See the doc: https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html > Formatting dates in the default 'en_US' format does not require > any initialization. (And we haven't been doing the described initialization for 'en_US' or any other locale; it's asynchronous, and we have a better plan for international formatting described in #45, using ffi.) --- lib/widgets/message_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 9af5889deb..11b87fb228 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,7 +2011,7 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeOnlyFormat = DateFormat('h:mm aa', 'en_US'); + static final _timeOnlyFormat = DateFormat('h:mm aa'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); From b7146a7ae28ce13ca7118d3ca2d46d47552111f2 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:56:31 -0400 Subject: [PATCH 09/14] msglist [nfc]: s/_timeOnlyFormat/_timeFormat/ --- lib/widgets/message_list.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 11b87fb228..24b7d2d578 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,7 +2011,7 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeOnlyFormat = DateFormat('h:mm aa'); + static final _timeFormat = DateFormat('h:mm aa'); static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); static final _fullFormat = DateFormat.yMMMd().add_jm(); @@ -2031,7 +2031,7 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeOnlyFormat.format(asDateTime); + case timeOnly: return _timeFormat.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } From 91d4ef3114a7f1ff5297d58fb90298c5197bbafc Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 15 Jul 2025 18:59:46 -0400 Subject: [PATCH 10/14] msglist [nfc]: Pull out a _timeFormatWithSeconds helper --- lib/widgets/message_list.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 24b7d2d578..796e070f2e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2012,7 +2012,8 @@ enum MessageTimestampStyle { } } static final _timeFormat = DateFormat('h:mm aa'); - static final _lightboxFormat = DateFormat.yMMMd().add_Hms(); + static final _timeFormatWithSeconds = DateFormat('Hms'); + static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormatWithSeconds.pattern); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. From 4d4928ffedafd0e58f330e53b803d4b2f95bb440 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 12:53:10 -0700 Subject: [PATCH 11/14] msglist [nfc]: Add helpers to resolve TwentyFourHourTimeMode We don't use these yet; coming up. --- lib/widgets/message_list.dart | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 796e070f2e..8665b9d3ef 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2011,9 +2011,30 @@ enum MessageTimestampStyle { return DateFormat.yMMMd().format(dateTime); } } - static final _timeFormat = DateFormat('h:mm aa'); - static final _timeFormatWithSeconds = DateFormat('Hms'); - static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormatWithSeconds.pattern); + + 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'); + + // ignore: unused_element + static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, + }; + + // ignore: unused_element + static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { + TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, + TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, + TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, + }; + + static final _lightboxFormat = + DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); static final _fullFormat = DateFormat.yMMMd().add_jm(); /// Format a [Message.timestamp] for this mode. @@ -2032,7 +2053,7 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeFormat.format(asDateTime); + case timeOnly: return _timeFormat12.format(asDateTime); case lightbox: return _lightboxFormat.format(asDateTime); case full: return _fullFormat.format(asDateTime); } From 736dc4d3f1f33ad3c116947a06bd58a2cb4413f7 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 12:55:29 -0700 Subject: [PATCH 12/14] msglist [nfc]: Use a helper field we just added --- lib/widgets/message_list.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8665b9d3ef..7c2c830a9b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2035,7 +2035,8 @@ enum MessageTimestampStyle { static final _lightboxFormat = DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); - static final _fullFormat = DateFormat.yMMMd().add_jm(); + static final _fullFormat = + DateFormat.yMMMd().addPattern(_timeFormatLocaleDefault.pattern); /// Format a [Message.timestamp] for this mode. // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) From 92bae685edc1e664565a9fc8b0532c4f863fd88d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 13:17:44 -0700 Subject: [PATCH 13/14] msglist: Follow user_settings.twenty_four_hour_time for message timestamps Fixes-partly: #1015 --- lib/widgets/message_list.dart | 22 ++++---- test/widgets/lightbox_test.dart | 6 +-- test/widgets/message_list_test.dart | 79 ++++++++++++++++++----------- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 7c2c830a9b..e3b1d69d6e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2019,25 +2019,18 @@ enum MessageTimestampStyle { static final _timeFormat24WithSeconds = DateFormat('Hms'); static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); - // ignore: unused_element static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { TwentyFourHourTimeMode.twelveHour => _timeFormat12, TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, }; - // ignore: unused_element static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, }; - static final _lightboxFormat = - DateFormat.yMMMd().addPattern(_timeFormat24WithSeconds.pattern); - static final _fullFormat = - DateFormat.yMMMd().addPattern(_timeFormatLocaleDefault.pattern); - /// Format a [Message.timestamp] for this mode. // TODO(i18n): locale-specific formatting (see #45 for a plan with ffi) String? format( @@ -2054,9 +2047,18 @@ enum MessageTimestampStyle { case dateOnlyRelative: return _formatDateOnlyRelative(asDateTime, now: now, zulipLocalizations: zulipLocalizations); - case timeOnly: return _timeFormat12.format(asDateTime); - case lightbox: return _lightboxFormat.format(asDateTime); - case full: return _fullFormat.format(asDateTime); + 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); } } } 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 43aa4ac79e..3636861a2e 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1641,55 +1641,76 @@ void main() { group('MessageTimestampStyle', () { void doTests( MessageTimestampStyle style, - List<(String timestampStr, String? expected)> cases, { + List<( + String timestampStr, + String? expectedTwelveHour, + String? expectedTwentyFourHour, + )> cases, { DateTime? now, }) { now ??= DateTime.parse("2023-01-10 12:00"); - for (final (timestampStr, expected) in cases) { - test('${style.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: TwentyFourHourTimeMode.localeDefault, - zulipLocalizations: zulipLocalizations); - check(result).equals(expected); + 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)]); + 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), - ("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"), + ("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), - ("2023-01-11 00:00", "Jan 11, 2023"), + ("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')]); + 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')]); + 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')]); + doTests(style, + [('2023-01-10 12:00', + 'Jan 10, 2023 12:00 PM', + 'Jan 10, 2023 12:00')]); } } }); From e120e00d666fed2f801ab38ad0522d34776baada Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 21 Jul 2025 19:59:03 -0700 Subject: [PATCH 14/14] content: Follow user_settings.twenty_four_hour_time in global times Fixes #1015. --- lib/widgets/content.dart | 17 ++++++++-- test/widgets/content_test.dart | 60 ++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 9 deletions(-) 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/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 prepareContent(WidgetTester tester, Widget child, { List 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 checkFontSizeRatio(WidgetTester tester, { required String targetHtml, required TargetFontSizeFinder targetFontSizeFinder, + bool wrapWithPerAccountStoreWidget = false, }) async { - await prepareContent(tester, plainContent( - '

header-plain $targetHtml

\n' - '

paragraph-plain $targetHtml

')); + await prepareContent(tester, wrapWithPerAccountStoreWidget: wrapWithPerAccountStoreWidget, + plainContent( + '

header-plain $targetHtml

\n' + '

paragraph-plain $targetHtml

')); final headerRootSpan = tester.renderObject(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 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('

$timeSpanHtml

')); + } testWidgets('smoke', (tester) async { - await prepareContent(tester, plainContent('

$timeSpanHtml

')); + 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( find.descendant(of: find.byType(GlobalTime), @@ -1100,6 +1144,8 @@ void main() { group('maintains font-size ratio with surrounding text', () { Future doCheck(WidgetTester tester, double Function(GlobalTime widget) sizeFromWidget) async { await checkFontSizeRatio(tester, + // We use the self-account's time-format setting. + wrapWithPerAccountStoreWidget: true, targetHtml: '', targetFontSizeFinder: (rootSpan) { late final double result;