diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 5ca1208723..0c22e6f63c 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -7,5 +7,12 @@ "wildcardMentionChannelDescription": "إخطار القناة", "wildcardMentionStreamDescription": "إخطار الدفق", "wildcardMentionAllDmDescription": "إخطار المستلمين", - "wildcardMentionTopicDescription": "إخطار الموضوع" + "wildcardMentionTopicDescription": "إخطار الموضوع", + "userLocalTime": "{userTime} الوقت المحلي", + "@userLocalTime": { + "description": "Current time in the user's timezone", + "placeholders": { + "userTime": {"type": "DateTime", "format": "jm"} + } + } } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 319c9e9dbc..4e5e433582 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -824,5 +824,12 @@ "zulipAppTitle": "Zulip", "@zulipAppTitle": { "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "userLocalTime": "{userTime} local time", + "@userLocalTime": { + "description": "Current time in the user's timezone", + "placeholders": { + "userTime": {"type": "DateTime", "format": "jm"} + } } } diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb index a66aede69e..80e0c8ac2a 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -16,5 +16,12 @@ "userRoleGuest": "ゲスト", "@userRoleGuest": {}, "userRoleUnknown": "不明", - "@userRoleUnknown": {} + "@userRoleUnknown": {}, + "userLocalTime": "現地時間 {userTime}", + "@userLocalTime": { + "description": "Current time in the user's timezone", + "placeholders": { + "userTime": {"type": "DateTime", "format": "jm"} + } + } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 770a670212..80e4a9b092 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -900,5 +900,12 @@ "unpinnedSubscriptionsLabel": "Odpięte", "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." + }, + "userLocalTime": "{userTime} czas lokalny", + "@userLocalTime": { + "description": "Current time in the user's timezone", + "placeholders": { + "userTime": {"type": "DateTime", "format": "jm"} + } } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index ef38533bb2..fcf17eb969 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -772,5 +772,12 @@ "errorMessageNotSent": "Сообщение не отправлено", "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." + }, + "userLocalTime": "{userTime} местное время", + "@userLocalTime": { + "description": "Current time in the user's timezone", + "placeholders": { + "userTime": {"type": "DateTime", "format": "jm"} + } } } diff --git a/assets/timezone/latest_all.tzf b/assets/timezone/latest_all.tzf new file mode 100644 index 0000000000..e6b113f161 Binary files /dev/null and b/assets/timezone/latest_all.tzf differ diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b3a8752ba1..e4239f9109 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1208,6 +1208,12 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Zulip'** String get zulipAppTitle; + + /// Current time in the user's timezone + /// + /// In en, this message translates to: + /// **'{userTime} local time'** + String userLocalTime(DateTime userTime); } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 967c7fb33c..57575501b7 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString الوقت المحلي'; + } } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 83d2af10b6..a4995b3ec6 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString local time'; + } } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 034bcd17d0..7448ae81f5 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '現地時間 $userTimeString'; + } } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 6416f59b08..79f0895f1f 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString local time'; + } } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index cab571c163..39e803ef51 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString czas lokalny'; + } } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index babbc976fd..abef5433d5 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString местное время'; + } } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index ac3b93b024..cdeae69b66 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -643,4 +643,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String userLocalTime(DateTime userTime) { + final intl.DateFormat userTimeDateFormat = intl.DateFormat.jm(localeName); + final String userTimeString = userTimeDateFormat.format(userTime); + + return '$userTimeString local time'; + } } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 50477ec01a..efffb07747 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -118,6 +118,12 @@ abstract class ZulipBinding { /// Outside tests, this just calls the [Stopwatch] constructor. Stopwatch stopwatch(); + /// Provides access to current time. + /// + /// Please refer to this issue: + /// https://github.com/dart-lang/sdk/issues/28985 + DateTime now(); + /// Provides device and operating system information, /// via package:device_info_plus. /// @@ -368,6 +374,9 @@ class LiveZulipBinding extends ZulipBinding { @override Stopwatch stopwatch() => Stopwatch(); + @override + DateTime now() => DateTime.now(); + @override Future get deviceInfo => _deviceInfo; late Future _deviceInfo; diff --git a/lib/model/store.dart b/lib/model/store.dart index 7603c7f452..319a036635 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -20,6 +20,7 @@ import '../api/route/realm.dart'; import '../log.dart'; import '../notifications/receive.dart'; import 'autocomplete.dart'; +import 'binding.dart'; import 'database.dart'; import 'emoji.dart'; import 'localizations.dart'; @@ -447,7 +448,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess /// /// To determine if a user is a full member, callers must also check that the /// user's role is at least [UserRole.member]. - bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { + bool hasPassedWaitingPeriod(User user) { // [User.dateJoined] is in UTC. For logged-in users, the format is: // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't @@ -459,7 +460,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess // See the related discussion: // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 final dateJoined = DateTime.parse(user.dateJoined); - return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; + final now = ZulipBinding.instance.now(); + return now.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } //////////////////////////////// @@ -483,7 +485,6 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess bool hasPostingPermission({ required ZulipStream inChannel, required User user, - required DateTime byDate, }) { final role = user.role; // We let the users with [unknown] role to send the message, then the server @@ -495,7 +496,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess case ChannelPostPolicy.fullMembers: { if (!role.isAtLeast(UserRole.member)) return false; return role == UserRole.member - ? hasPassedWaitingPeriod(user, byDate: byDate) + ? hasPassedWaitingPeriod(user) : true; } case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2b1756e4fe..55c7894f73 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1410,8 +1410,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM case ChannelNarrow(:final streamId): case TopicNarrow(:final streamId): final channel = store.streams[streamId]; - if (channel == null || !store.hasPostingPermission(inChannel: channel, - user: selfUser, byDate: DateTime.now())) { + if (channel == null || !store.hasPostingPermission(inChannel: channel, user: selfUser)) { return _ErrorBanner(label: ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 06ecff110f..51fb2812c5 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -7,6 +7,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/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -1271,19 +1272,19 @@ class DateText extends StatelessWidget { ), formatHeaderDate( zulipLocalizations, - DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), - now: DateTime.now())); + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000))); } } @visibleForTesting String formatHeaderDate( ZulipLocalizations zulipLocalizations, - DateTime dateTime, { - required DateTime now, -}) { - assert(!dateTime.isUtc && !now.isUtc, - '`dateTime` and `now` need to be in local time.'); + DateTime dateTime, +) { + assert(!dateTime.isUtc, + '`dateTime` need to be in local time.'); + + final now = ZulipBinding.instance.now(); if (dateTime.year == now.year && dateTime.month == now.month && diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 327910f6c0..e741354c0d 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:timezone/timezone.dart' as tz; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/binding.dart'; import '../model/content.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -90,7 +94,11 @@ class ProfilePage extends StatelessWidget { style: _TextStyles.primaryFieldText), // TODO(#197) render user status // TODO(#196) render active status - // TODO(#292) render user local time + DefaultTextStyle.merge( + textAlign: TextAlign.center, + style: _TextStyles.primaryFieldText, + child: UserLocalTimeText(user: user) + ), _ProfileDataTable(profileData: user.profileData), const SizedBox(height: 16), @@ -307,3 +315,66 @@ class _UserWidget extends StatelessWidget { ]))); } } + +/// The text of current time in [user]'s timezone. +class UserLocalTimeText extends StatefulWidget { + const UserLocalTimeText({ + super.key, + required this.user, + }); + + final User user; + + /// Initialize the timezone database used to know time difference from a timezone string. + /// + /// Usually, database initialization is done using `initializeTimeZones`, but it takes >100ms and not asynchronous. + /// So, we initialize database from the assets file copied from timezone library. + /// This file is checked up-to-date in `test/widgets/profile_test.dart`. + static Future initializeTimezonesUsingAssets() async { + final blob = Uint8List.sublistView(await rootBundle.load('assets/timezone/latest_all.tzf')); + tz.initializeDatabase(blob); + } + + @override + State createState() => _UserLocalTimeTextState(); +} + +class _UserLocalTimeTextState extends State { + late final Timer _timer; + final StreamController _streamController = StreamController(); + Stream get _stream => _streamController.stream; + + @override + void initState() { + _streamController.add(ZulipBinding.instance.now()); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { _streamController.add(ZulipBinding.instance.now()); }); + super.initState(); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + Stream _getDisplayLocalTimeFor(User user, ZulipLocalizations zulipLocalizations) async* { + if (!tz.timeZoneDatabase.isInitialized) await UserLocalTimeText.initializeTimezonesUsingAssets(); + + await for (final DateTime time in _stream) { + final location = tz.getLocation(user.timezone); + final localTime = tz.TZDateTime.from(time, location); + yield zulipLocalizations.userLocalTime(localTime); + } + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _getDisplayLocalTimeFor(widget.user, ZulipLocalizations.of(context)), + builder: (context, snapshot) { + if (snapshot.hasError) Error.throwWithStackTrace(snapshot.error!, snapshot.stackTrace!); + return Text(snapshot.data ?? ''); + } + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 4ee3317f8d..e709f0ef39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1104,6 +1104,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + url: "https://pub.dev" + source: hosted + version: "0.10.0" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cc1d93cca8..f230c091f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: share_plus_platform_interface: ^5.0.2 sqlite3: ^2.4.0 sqlite3_flutter_libs: ^0.5.13 + timezone: ^0.10.0 url_launcher: ^6.1.11 url_launcher_android: ">=6.1.0" video_player: ^2.8.3 @@ -114,6 +115,7 @@ flutter: uses-material-design: true assets: + - assets/timezone/latest_all.tzf - assets/Noto_Color_Emoji/LICENSE - assets/Pygments/AUTHORS.txt - assets/Pygments/LICENSE.txt diff --git a/test/example_data.dart b/test/example_data.dart index 6b84bf185c..8071d4727a 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -117,6 +117,7 @@ User user({ bool? isActive, bool? isBot, UserRole? role, + String? timezone, String? avatarUrl, Map? profileData, }) { @@ -134,7 +135,7 @@ User user({ botType: null, botOwnerId: null, role: role ?? UserRole.member, - timezone: 'UTC', + timezone: timezone ?? 'UTC', avatarUrl: avatarUrl, avatarVersion: 0, profileData: profileData, diff --git a/test/model/binding.dart b/test/model/binding.dart index 039d6c3787..3102173018 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -214,6 +214,9 @@ class TestZulipBinding extends ZulipBinding { @override Stopwatch stopwatch() => clock.stopwatch(); + @override + DateTime now() => clock.now(); + /// The value that `ZulipBinding.instance.deviceInfo` should return. BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult; static const _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33, release: '13'); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index bc393d6d6f..7d3fb35f73 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -260,9 +261,11 @@ void main() { for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' 'passed waiting period by $currentDate', () { - final user = eg.user(dateJoined: dateJoined); - check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) - .equals(hasPassedWaitingPeriod); + withClock(Clock.fixed(currentDate), () { + final user = eg.user(dateJoined: dateJoined); + check(store.hasPassedWaitingPeriod(user)) + .equals(hasPassedWaitingPeriod); + }); }); } }); @@ -306,11 +309,10 @@ void main() { test('"${role.name}" user ${canPost ? 'can' : "can't"} post in channel ' 'with "${policy.name}" policy', () { final store = eg.store(); + // we don't use `withClock` because current time is not actually relevant for + // these test cases; for the ones which it is, they're practiced below. final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role), - // [byDate] is not actually relevant for these test cases; for the - // ones which it is, they're practiced below. - byDate: DateTime.now()); + inChannel: eg.stream(channelPostPolicy: policy), user: eg.user(role: role)); check(actual).equals(canPost); }); } @@ -324,21 +326,23 @@ void main() { role: UserRole.member, dateJoined: dateJoined); test('a "full" member -> can post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final hasPermission = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 10, 00)); - check(hasPermission).isTrue(); + withClock(Clock.fixed(DateTime.utc(2024, 11, 28, 10, 00)), () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final hasPermission = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00')); + check(hasPermission).isTrue(); + }); }); test('not a "full" member -> cannot post in the channel', () { - final store = localStore(realmWaitingPeriodThreshold: 3); - final actual = store.hasPostingPermission( - inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), - user: memberUser(dateJoined: '2024-11-25T10:00+00:00'), - byDate: DateTime.utc(2024, 11, 28, 09, 59)); - check(actual).isFalse(); + withClock(Clock.fixed(DateTime.utc(2024, 11, 28, 09, 59)), () { + final store = localStore(realmWaitingPeriodThreshold: 3); + final actual = store.hasPostingPermission( + inChannel: eg.stream(channelPostPolicy: ChannelPostPolicy.fullMembers), + user: memberUser(dateJoined: '2024-11-25T10:00+00:00')); + check(actual).isFalse(); + }); }); }); }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 3f79f8cae6..06f871408f 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; 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'; @@ -1107,7 +1108,7 @@ void main() { .initNarrow.equals(DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId)); await tester.pumpAndSettle(); }); - + testWidgets('does not navigate on tapping recipient header in DmNarrow', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -1129,7 +1130,6 @@ 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), @@ -1144,8 +1144,10 @@ void main() { ]; for (final (dateTime, expected) in testCases) { test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); + withClock(Clock.fixed(DateTime.parse("2023-01-10 12:00")), () { + check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime))) + .equals(expected); + }); }); } }); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 38f6c223e3..0483319412 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,6 +1,12 @@ import 'package:checks/checks.dart'; +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/src/tzdb.dart' as tz; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -66,6 +72,14 @@ CustomProfileField mkCustomProfileField( ); } +void resetTimezones() { + tz.initializeDatabase([]); +} + +Subject checkUserLocalTimeText(WidgetTester tester) => + check( + tester.widget(find.descendant(of: find.byType(UserLocalTimeText), matching: find.byType(Text))).data).isNotNull(); + void main() { TestZulipBinding.ensureInitialized(); @@ -317,5 +331,96 @@ void main() { check(find.textContaining(longString).evaluate()).length.equals(7); }); + + group('UserLocalTimeText', () { + setUp(() async { + await UserLocalTimeText.initializeTimezonesUsingAssets(); + }); + + test('assets; ensure the timezone database used to display users\' local time is up-to-date', () async { + tz.initializeTimeZones(); + final latestTimezones = tz.tzdbSerialize(tz.timeZoneDatabase); + + await UserLocalTimeText.initializeTimezonesUsingAssets(); + final currentTimezones = tz.tzdbSerialize(tz.timeZoneDatabase); + + check( + listEquals(currentTimezones, latestTimezones), + because: + 'the timezone database used to display users\' local time is not up-to-date, please copy `package:timezone/data/latest_all.tzf` to `assets/timezone/latest_all.tzf`', + ).isTrue(); + }); + + final testCases = [( + description: 'simple usecase', + currentTimezone: 'America/Los_Angeles', + currentYear: 2025, currentMonth: 02, currentDay: 01, currentHour: 12, currentMinute: 00, + userTimezone: 'America/New_York', + equalsTo: '3:00 PM local time' + ), ( + description: 'abbreviation usecase', + currentTimezone: 'Europe/Brussels', + currentYear: 2025, currentMonth: 02, currentDay: 01, currentHour: 12, currentMinute: 00, + userTimezone: 'CET', + equalsTo: '12:00 PM local time' + ), ( + description: 'DST usecase', + currentTimezone: 'Europe/London', + currentYear: 2025, currentMonth: 08, currentDay: 01, currentHour: 12, currentMinute: 00, + userTimezone: 'UTC', + equalsTo: '11:00 AM local time' + ) + ]; + + for ( + final ( + :description, + :currentTimezone, + :currentYear, + :currentMonth, + :currentDay, + :currentHour, + :currentMinute, :userTimezone, :equalsTo) in testCases) { + testWidgets('page builds; $description', (tester) async { + final currentTime = tz.TZDateTime( + tz.getLocation(currentTimezone), + currentYear, + currentMonth, + currentDay, + currentHour, + currentMinute + ); + resetTimezones(); + + await withClock(Clock.fixed(currentTime), () async { + final user = eg.user(userId: 1, timezone: userTimezone); + await setupPage(tester, pageUserId: user.userId, users: [user]); + + checkUserLocalTimeText(tester).equals(equalsTo); + }); + }); + } + + testWidgets('page builds; keep "current" local time current', (tester) async { + withClock(Clock.fixed(tz.TZDateTime(tz.getLocation('Europe/London'), 2025, 02, 01, 12, 00)), () { + FakeAsync().run((async) { + final user = eg.user(userId: 1, timezone: 'Europe/London'); + setupPage(tester, pageUserId: user.userId, users: [user]); + async.flushMicrotasks(); + checkUserLocalTimeText(tester).equals('12:00 PM local time'); + + async.elapse(Duration(minutes: 1)); + tester.pumpAndSettle(); + async.flushMicrotasks(); + checkUserLocalTimeText(tester).equals('12:01 PM local time'); + + async.elapse(Duration(minutes: 1)); + tester.pumpAndSettle(); + async.flushMicrotasks(); + checkUserLocalTimeText(tester).equals('12:02 PM local time'); + }); + }); + }); + }); }); }