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

Issue:
profile: Show when the user was last active
On a user's profile, we should show the time they were last known to be active, e.g., "5 hours ago". For how to format the time, see #45.


Discussion hints:
Hi team 👋,

I'd love to work on this issue! I have experience with Flutter and handling datetime formatting. I can implement a user-friendly "last active" display using libraries like `timeago` or `intl` to show values such as:

Example implementaion code using `timeago`:-
```
import 'package:timeago/timeago.dart' as timeago;

DateTime lastActiveTime = DateTime.parse("2025-07-20T16:47:00"); // Example timestamp
String lastSeen = timeago.format(lastActiveTime);

Text("Last active: $lastSeen");
```

or if using a formatted date and time such as "Yesterday at 4:47 PM", best to go with `intl`

- "Just now"
- "5 minutes ago"
- "Yesterday at 4:47 PM"
- "Last Monday at 2:30 PM"

If assigned, I can get started right away and submit a PR soon.
Thanks for your interest in this issue! Please see the issue description for how we'd like this to be implemented, and why we don't want to use `timeago` or `intl`:

> For how to format the time, see https://github.com/zulip/zulip-flutter/issues/45.

Pull request: Show last-active time on profile page
Fixes #1647.

There are probably ways the logic here can be improved.  But we can do that in tandem with the relative times on the lightbox (#45), once we have ready access to CLDR data via FFI.

~~Draft because I want to write some tests before merging; my manual testing hasn't reached all the different cases in this logic.~~

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

Fix patch:
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 66ef1ee004..dedffe9b47 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -874,6 +874,50 @@
   "@yesterday": {
     "description": "Term to use to reference the previous day."
   },
+  "userActiveNow": "Active now",
+  "@userActiveNow": {
+    "description": "Indicates a user is currently active on Zulip (not idle or offline)"
+  },
+  "userIdle": "Idle",
+  "@userIdle": {
+    "description": "Indicates a user is currently idle on Zulip (not active, but not offline)"
+  },
+  "userActiveMinutesAgo": "Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago",
+  "@userActiveMinutesAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "minutes": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveHoursAgo": "Active {hours, plural, =1{1 hour} other{{hours} hours}} ago",
+  "@userActiveHoursAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "hours": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveYesterday": "Active yesterday",
+  "@userActiveYesterday": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)"
+  },
+  "userActiveDaysAgo": "Active {days, plural, =1{1 day} other{{days} days}} ago",
+  "@userActiveDaysAgo": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)",
+    "placeholders": {
+      "days": {"type": "int", "example": "5"}
+    }
+  },
+  "userActiveDate": "Active {date}",
+  "@userActiveDate": {
+    "description": "Indicates the date when a user was last active on Zulip (who is currently offline).\n\nThe date might be day and month if recent, or day, month, and year if less recent.",
+    "placeholders": {
+      "date": {"type": "String", "example": "Aug 1, 2024"}
+    }
+  },
+  "userNotActiveInYear": "Not active in the last year",
+  "@userNotActiveInYear": {
+    "description": "Indicates when a user was last active on Zulip (who is currently offline)"
+  },
   "invisibleMode": "Invisible mode",
   "@invisibleMode": {
     "description": "Label for the 'Invisible mode' switch on the profile page."
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 51166b29aa..8b0de0b1b3 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1301,6 +1301,56 @@ abstract class ZulipLocalizations {
   /// **'Yesterday'**
   String get yesterday;
 
+  /// Indicates a user is currently active on Zulip (not idle or offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active now'**
+  String get userActiveNow;
+
+  /// Indicates a user is currently idle on Zulip (not active, but not offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Idle'**
+  String get userIdle;
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {minutes, plural, =1{1 minute} other{{minutes} minutes}} ago'**
+  String userActiveMinutesAgo(int minutes);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {hours, plural, =1{1 hour} other{{hours} hours}} ago'**
+  String userActiveHoursAgo(int hours);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active yesterday'**
+  String get userActiveYesterday;
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Active {days, plural, =1{1 day} other{{days} days}} ago'**
+  String userActiveDaysAgo(int days);
+
+  /// Indicates the date when a user was last active on Zulip (who is currently offline).
+  ///
+  /// The date might be day and month if recent, or day, month, and year if less recent.
+  ///
+  /// In en, this message translates to:
+  /// **'Active {date}'**
+  String userActiveDate(String date);
+
+  /// Indicates when a user was last active on Zulip (who is currently offline)
+  ///
+  /// In en, this message translates to:
+  /// **'Not active in the last year'**
+  String get userNotActiveInYear;
+
   /// Label for the 'Invisible mode' switch on the profile page.
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index bcf71ff75d..7c63614c82 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 5989434931..5d44111e4e 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -745,6 +745,56 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get yesterday => 'Gestern';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 1dc66ac100..3c96f8bdd2 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 84ddde19b4..31849002e9 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -737,6 +737,56 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 43accf333c..c9cff2ec10 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -738,6 +738,56 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   @override
   String get yesterday => 'Ieri';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 01cfdfc1c7..19051577a2 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -720,6 +720,56 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 1ee9cc4304..e1923091e7 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index dae4675941..a56afc6c1c 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -734,6 +734,56 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get yesterday => 'Wczoraj';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Tryb ukrycia';
 
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index ce7d41533a..bf986a9da3 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -739,6 +739,56 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get yesterday => 'Вчера';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Режим невидимости';
 
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index a9cb388332..ee0840259f 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -725,6 +725,56 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get yesterday => 'Včera';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 0f45bd9e28..ec7e3c7c22 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -749,6 +749,56 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   @override
   String get yesterday => 'Včeraj';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 7950acfb15..4d8fc8ab9e 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -738,6 +738,56 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   @override
   String get yesterday => 'Учора';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Невидимий режим';
 
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 39e86f6960..6d6ba8579b 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -723,6 +723,56 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get yesterday => 'Yesterday';
 
+  @override
+  String get userActiveNow => 'Active now';
+
+  @override
+  String get userIdle => 'Idle';
+
+  @override
+  String userActiveMinutesAgo(int minutes) {
+    String _temp0 = intl.Intl.pluralLogic(
+      minutes,
+      locale: localeName,
+      other: '$minutes minutes',
+      one: '1 minute',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveHoursAgo(int hours) {
+    String _temp0 = intl.Intl.pluralLogic(
+      hours,
+      locale: localeName,
+      other: '$hours hours',
+      one: '1 hour',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String get userActiveYesterday => 'Active yesterday';
+
+  @override
+  String userActiveDaysAgo(int days) {
+    String _temp0 = intl.Intl.pluralLogic(
+      days,
+      locale: localeName,
+      other: '$days days',
+      one: '1 day',
+    );
+    return 'Active $_temp0 ago';
+  }
+
+  @override
+  String userActiveDate(String date) {
+    return 'Active $date';
+  }
+
+  @override
+  String get userNotActiveInYear => 'Not active in the last year';
+
   @override
   String get invisibleMode => 'Invisible mode';
 
diff --git a/lib/model/presence.dart b/lib/model/presence.dart
index 590c2d3ceb..882f12031c 100644
--- a/lib/model/presence.dart
+++ b/lib/model/presence.dart
@@ -96,8 +96,7 @@ class Presence extends HasRealmStore with ChangeNotifier {
           newUserInput: false);
     }
     if (!pingOnly) {
-      _map = result.presences!;
-      notifyListeners();
+      _handlePresenceResponse(result.presences!);
     }
   }
 
@@ -124,6 +123,16 @@ class Presence extends HasRealmStore with ChangeNotifier {
     super.dispose();
   }
 
+  @visibleForTesting
+  void debugHandlePresenceResponse(Map<int, PerUserPresence> presences) {
+    _handlePresenceResponse(presences);
+  }
+
+  void _handlePresenceResponse(Map<int, PerUserPresence> presences) {
+    _map = presences;
+    notifyListeners();
+  }
+
   /// The [PresenceStatus] for [userId], or null if the user is offline.
   PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) {
     final now = utcNow.millisecondsSinceEpoch ~/ 1000;
@@ -143,6 +152,31 @@ class Presence extends HasRealmStore with ChangeNotifier {
     }
   }
 
+  /// The timestamp when the given user was "last active", if any.
+  ///
+  /// This is meaningful only when [presenceStatusForUser] is null.
+  /// When that method returns active or idle, the user should be displayed
+  /// with a description like "Active now" or "Idle" rather than
+  /// one like "Last active $duration ago" that uses this timestamp.
+  int? userLastActive(int userId) {
+    // The corresponding implementation on web is complicated;
+    // but the actual behavior seems to be this simple.
+    //
+    // In web, see buddy_data.user_last_seen_time_status; the last-active time
+    // is used only when the status is offline (vs active or idle).
+    // The timestamp comes via presence.last_active_date from the data structure
+    // fed by presence.status_from_raw.
+    //
+    // That status_from_raw function sometimes uses idle_timestamp;
+    // but only when status idle, where the timestamp will be ignored anyway.
+    // It also consults the equivalent of [User.dateJoined] as a fallback,
+    // for when processing a user who has "never logged in"... but it's
+    // not clear that function ever gets called in such a case.
+    // Those wrinkles aside, it always uses active_timestamp.
+
+    return _map[userId]?.activeTimestamp;
+  }
+
   void handlePresenceEvent(PresenceEvent event) {
     // TODO(#1618)
   }
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 4f3e72442f..070c20fb27 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -814,7 +814,8 @@ class PerAccountStore extends PerAccountStoreBase with
         typingStatus.handleTypingEvent(event);
 
       case PresenceEvent():
-        // TODO handle
+        assert(debugLog("server event: presence ${event.userId}"));
+        // TODO(#1618) handle
         break;
 
       case ReactionEvent():
diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart
index 461b2ec1b7..3748e39280 100644
--- a/lib/widgets/profile.dart
+++ b/lib/widgets/profile.dart
@@ -1,13 +1,16 @@
 import 'dart:convert';
 
 import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
 
 import '../api/model/model.dart';
 import '../api/route/settings.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../log.dart';
+import '../model/binding.dart';
 import '../model/content.dart';
 import '../model/narrow.dart';
+import '../model/presence.dart';
 import 'app_bar.dart';
 import 'button.dart';
 import 'content.dart';
@@ -42,7 +45,6 @@ class ProfilePage extends StatelessWidget {
       page: ProfilePage(userId: userId));
   }
 
-
   @override
   Widget build(BuildContext context) {
     final zulipLocalizations = ZulipLocalizations.of(context);
@@ -56,7 +58,9 @@ class ProfilePage extends StatelessWidget {
       .merge(weightVariableTextStyle(context, wght: 700));
 
     final userStatus = store.getUserStatus(userId);
+
     final displayEmail = user.deliveryEmail;
+
     final items = [
       Center(
         child: Avatar(
@@ -93,6 +97,10 @@ class ProfilePage extends StatelessWidget {
           textAlign: TextAlign.center,
           style: TextStyle(fontSize: 18, height: 22 / 18,
             color: DesignVariables.of(context).userStatusText)),
+      if (!user.isBot)
+        _LastActiveTime(userId: userId),
+
+      const SizedBox(height: 8),
       if (displayEmail != null)
         Text(displayEmail,
           textAlign: TextAlign.center,
@@ -139,6 +147,106 @@ class ProfilePage extends StatelessWidget {
   }
 }
 
+class _LastActiveTime extends StatefulWidget {
+  const _LastActiveTime({required this.userId});
+
+  final int userId;
+
+  @override
+  State<_LastActiveTime> createState() => _LastActiveTimeState();
+}
+
+class _LastActiveTimeState extends State<_LastActiveTime> with PerAccountStoreAwareStateMixin {
+  Presence? model;
+
+  @override
+  void onNewStore() {
+    model?.removeListener(_modelChanged);
+    model = PerAccountStoreWidget.of(context).presence
+      ..addListener(_modelChanged);
+  }
+
+  @override
+  void dispose() {
+    model!.removeListener(_modelChanged);
+    super.dispose();
+  }
+
+  void _modelChanged() {
+    setState(() {
+      // The actual state lives in [model].
+      // This method was called because that just changed.
+    });
+  }
+
+  String _lastActiveText(ZulipLocalizations zulipLocalizations) {
+    // TODO(#45): revise this relative-time logic in light of a future solution
+    //   for the lightbox, e.g. using ICU/CLDR via FFI.  See discussion:
+    //     https://github.com/zulip/zulip-flutter/pull/1793#issuecomment-3169228753
+
+    // TODO(#293), TODO(#891): auto-rebuild as relative time changes
+    final nowDate = ZulipBinding.instance.utcNow();
+
+    final status = model!.presenceStatusForUser(widget.userId,
+      utcNow: nowDate);
+    switch (status) {
+      case PresenceStatus.active: return zulipLocalizations.userActiveNow;
+      case PresenceStatus.idle:   return zulipLocalizations.userIdle;
+      case null:                  break; // handle below
+    }
+
+    final timestamp = model!.userLastActive(widget.userId);
+    if (timestamp == null) return zulipLocalizations.userNotActiveInYear;
+
+    // Compare web's timerender.last_seen_status_from_date.
+    final now = nowDate.millisecondsSinceEpoch ~/ 1000;
+    final ageSeconds = now - timestamp;
+    if (ageSeconds <= 0) {
+      // TODO or perhaps show full time, to help user in case of clock skew
+      return zulipLocalizations.userActiveNow;
+    } else if (ageSeconds < 60 * 60) {
+      return zulipLocalizations.userActiveMinutesAgo(ageSeconds ~/ 60);
+    } else if (ageSeconds < 24 * 60 * 60) {
+      return zulipLocalizations.userActiveHoursAgo(ageSeconds ~/ (60 * 60));
+    }
+
+    final todayNoon = nowDate.toLocal()
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0);
+    final presenceNoon = DateTime.fromMillisecondsSinceEpoch(
+        timestamp * 1000, isUtc: false)
+      .copyWith(hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0);
+    final ageCalendarDays = (todayNoon.difference(presenceNoon)
+      .inSeconds / (24 * 60 * 60)).round();
+    if (ageCalendarDays <= 0) {
+      // The timestamp was at least 24 hours ago.
+      // If it's somehow the same or a future calendar day, then this must be a
+      // really messy time zone.  Hopefully no real time zone makes this possible.
+      return zulipLocalizations.userActiveYesterday;
+    } else if (ageCalendarDays == 1) {
+      return zulipLocalizations.userActiveYesterday;
+    } else if (ageCalendarDays < 90) {
+      return zulipLocalizations.userActiveDaysAgo(ageCalendarDays);
+    }
+
+    final DateFormat format;
+    if (presenceNoon.year == todayNoon.year) {
+      format = DateFormat.MMMd();
+    } else {
+      format = DateFormat.yMMMd();
+    }
+    return zulipLocalizations.userActiveDate(format.format(presenceNoon));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    return Text(_lastActiveText(zulipLocalizations),
+      textAlign: TextAlign.center,
+      style: TextStyle(fontSize: 18, height: 22 / 18,
+        color: DesignVariables.of(context).userStatusText));
+  }
+}
+
 class _SetStatusButton extends StatelessWidget {
   const _SetStatusButton();
 


Test patch:
diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart
index a7b74356ab..c79e3fb5b9 100644
--- a/test/widgets/profile_test.dart
+++ b/test/widgets/profile_test.dart
@@ -1,6 +1,7 @@
 import 'dart:io';
 
 import 'package:checks/checks.dart';
+import 'package:clock/clock.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -119,6 +120,87 @@ void main() {
     check(find.text(longString).evaluate()).isNotEmpty();
   });
 
+  group('_LastActiveTime', () {
+    Future<void> update(WidgetTester tester, User user, {
+      required int activeSeconds,
+      int? idleSeconds,
+    }) async {
+      idleSeconds ??= activeSeconds + 3600;
+      final now = clock.now().millisecondsSinceEpoch ~/ 1000;
+      final presence = PerUserPresence(
+        activeTimestamp: now - activeSeconds,
+        idleTimestamp: now - idleSeconds,
+      );
+      store.presence.debugHandlePresenceResponse({user.userId: presence});
+      await tester.pump();
+    }
+
+    testWidgets('active, idle, never', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+      check(find.text('Not active in the last year')).findsOne();
+
+      await update(tester, user, activeSeconds: 3600, idleSeconds: 30);
+      check(find.text('Idle')).findsOne();
+
+      await update(tester, user, activeSeconds: 30);
+      check(find.text('Active now')).findsOne();
+    });
+
+    testWidgets('various ages', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+
+      // These tests could be more detailed in making sure the behavior is
+      // exactly the way it currently is.  But save that for a future where
+      // we've made a pass over the logic to ensure we're happy with that spec.
+
+      await update(tester, user, activeSeconds: 10 * 60);
+      check(find.text('Active 10 minutes ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 61 * 60);
+      check(find.text('Active 1 hour ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 20 * 60 * 60);
+      check(find.text('Active 20 hours ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 24 * 60 * 60);
+      check(find.text('Active yesterday')).findsOne();
+
+      await update(tester, user, activeSeconds: 2 * 24 * 60 * 60);
+      check(find.text('Active 2 days ago')).findsOne();
+
+      await update(tester, user, activeSeconds: 80 * 24 * 60 * 60);
+      check(find.text('Active 80 days ago')).findsOne();
+    });
+
+    testWidgets('dates', (tester) async {
+      final user = eg.user();
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+
+      final now = DateTime.parse('2025-08-01 12:00');
+      await withClock(Clock.fixed(now), () async {
+        await update(tester, user, activeSeconds: now.difference(
+          DateTime.parse('2025-04-01 12:00')).inSeconds);
+        check(find.text('Active Apr 1')).findsOne();
+
+        await update(tester, user, activeSeconds: now.difference(
+          DateTime.parse('2024-04-01 12:00')).inSeconds);
+        check(find.text('Active Apr 1, 2024')).findsOne();
+      });
+    });
+
+    testWidgets('omit for bots', (tester) async {
+      final user = eg.user(isBot: true);
+      await setupPage(tester, users: [user], pageUserId: user.userId);
+      await update(tester, user, activeSeconds: 30);
+      check(find.text('Active now')).findsNothing();
+      check(find.textContaining('Active')).findsNothing();
+      check(find.textContaining('Idle')).findsNothing();
+      check(find.textContaining('Not active')).findsNothing();
+    });
+  });
+
   group('custom profile fields', () {
     testWidgets('page builds; profile page renders with profileData', (tester) async {
       await setupPage(tester,