From 129c215a02fb738a02686430ad5367f0e5dc8a28 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sat, 14 Jun 2025 11:39:09 +0800
Subject: [PATCH] :sparkles: Finishing up the profile page data displaying

---
 assets/i18n/en-US.json                     |   3 +-
 lib/models/user.dart                       |   6 +-
 lib/models/user.freezed.dart               |  34 +++---
 lib/models/user.g.dart                     |   6 +-
 lib/screens/account/me/update.dart         |   6 +-
 lib/screens/account/profile.dart           | 115 ++++++++++++++++++++-
 lib/screens/explore.g.dart                 |   2 +-
 lib/services/time.dart                     |  36 +++++++
 lib/widgets/account/account_nameplate.dart |   4 +-
 lib/widgets/account/account_pfc.dart       |  23 +++++
 10 files changed, 201 insertions(+), 34 deletions(-)

diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json
index de89f5b..8497434 100644
--- a/assets/i18n/en-US.json
+++ b/assets/i18n/en-US.json
@@ -425,5 +425,6 @@
   "checkInResultT2": "Mid",
   "checkInResultT3": "Good",
   "checkInResultT4": "Best",
-  "accountProfileView": "View Profile"
+  "accountProfileView": "View Profile",
+  "unspecified": "Unspecified"
 }
diff --git a/lib/models/user.dart b/lib/models/user.dart
index 151272f..ac3b45b 100644
--- a/lib/models/user.dart
+++ b/lib/models/user.dart
@@ -27,9 +27,9 @@ sealed class SnAccount with _$SnAccount {
 sealed class SnAccountProfile with _$SnAccountProfile {
   const factory SnAccountProfile({
     required String id,
-    required String? firstName,
-    required String? middleName,
-    required String? lastName,
+    @Default('') String firstName,
+    @Default('') String middleName,
+    @Default('') String lastName,
     @Default('') String bio,
     @Default('') String gender,
     @Default('') String pronouns,
diff --git a/lib/models/user.freezed.dart b/lib/models/user.freezed.dart
index 9e045d8..2545102 100644
--- a/lib/models/user.freezed.dart
+++ b/lib/models/user.freezed.dart
@@ -200,7 +200,7 @@ $SnAccountProfileCopyWith<$Res> get profile {
 /// @nodoc
 mixin _$SnAccountProfile {
 
- String get id; String? get firstName; String? get middleName; String? get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
+ String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
 /// Create a copy of SnAccountProfile
 /// with the given fields replaced by the non-null parameter values.
 @JsonKey(includeFromJson: false, includeToJson: false)
@@ -233,7 +233,7 @@ abstract mixin class $SnAccountProfileCopyWith<$Res>  {
   factory $SnAccountProfileCopyWith(SnAccountProfile value, $Res Function(SnAccountProfile) _then) = _$SnAccountProfileCopyWithImpl;
 @useResult
 $Res call({
- String id, String? firstName, String? middleName, String? lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
+ String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 });
 
 
@@ -250,13 +250,13 @@ class _$SnAccountProfileCopyWithImpl<$Res>
 
 /// Create a copy of SnAccountProfile
 /// with the given fields replaced by the non-null parameter values.
-@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
+@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
   return _then(_self.copyWith(
 id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
-as String,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
-as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable
-as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
-as String?,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable
+as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
+as String,middleName: null == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable
+as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
+as String,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable
 as String,gender: null == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable
 as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast_nullable_to_non_nullable
 as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
@@ -332,13 +332,13 @@ $SnVerificationMarkCopyWith<$Res>? get verification {
 @JsonSerializable()
 
 class _SnAccountProfile implements SnAccountProfile {
-  const _SnAccountProfile({required this.id, required this.firstName, required this.middleName, required this.lastName, this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, this.lastSeenAt, this.activeBadge, required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt});
+  const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, this.lastSeenAt, this.activeBadge, required this.experience, required this.level, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt});
   factory _SnAccountProfile.fromJson(Map<String, dynamic> json) => _$SnAccountProfileFromJson(json);
 
 @override final  String id;
-@override final  String? firstName;
-@override final  String? middleName;
-@override final  String? lastName;
+@override@JsonKey() final  String firstName;
+@override@JsonKey() final  String middleName;
+@override@JsonKey() final  String lastName;
 @override@JsonKey() final  String bio;
 @override@JsonKey() final  String gender;
 @override@JsonKey() final  String pronouns;
@@ -390,7 +390,7 @@ abstract mixin class _$SnAccountProfileCopyWith<$Res> implements $SnAccountProfi
   factory _$SnAccountProfileCopyWith(_SnAccountProfile value, $Res Function(_SnAccountProfile) _then) = __$SnAccountProfileCopyWithImpl;
 @override @useResult
 $Res call({
- String id, String? firstName, String? middleName, String? lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
+ String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
 });
 
 
@@ -407,13 +407,13 @@ class __$SnAccountProfileCopyWithImpl<$Res>
 
 /// Create a copy of SnAccountProfile
 /// with the given fields replaced by the non-null parameter values.
-@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = freezed,Object? middleName = freezed,Object? lastName = freezed,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
+@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
   return _then(_SnAccountProfile(
 id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
-as String,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
-as String?,middleName: freezed == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable
-as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
-as String?,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable
+as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
+as String,middleName: null == middleName ? _self.middleName : middleName // ignore: cast_nullable_to_non_nullable
+as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
+as String,bio: null == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable
 as String,gender: null == gender ? _self.gender : gender // ignore: cast_nullable_to_non_nullable
 as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast_nullable_to_non_nullable
 as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart
index a3ae041..6668dfa 100644
--- a/lib/models/user.g.dart
+++ b/lib/models/user.g.dart
@@ -43,9 +43,9 @@ Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
 _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
     _SnAccountProfile(
       id: json['id'] as String,
-      firstName: json['first_name'] as String?,
-      middleName: json['middle_name'] as String?,
-      lastName: json['last_name'] as String?,
+      firstName: json['first_name'] as String? ?? '',
+      middleName: json['middle_name'] as String? ?? '',
+      lastName: json['last_name'] as String? ?? '',
       bio: json['bio'] as String? ?? '',
       gender: json['gender'] as String? ?? '',
       pronouns: json['pronouns'] as String? ?? '',
diff --git a/lib/screens/account/me/update.dart b/lib/screens/account/me/update.dart
index 655befd..2ebd5dd 100644
--- a/lib/screens/account/me/update.dart
+++ b/lib/screens/account/me/update.dart
@@ -121,7 +121,9 @@ class UpdateProfileScreen extends HookConsumerWidget {
     }
 
     final formKeyProfile = useMemoized(GlobalKey<FormState>.new, const []);
-    final birthday = useState<DateTime?>(user.value!.profile.birthday);
+    final birthday = useState<DateTime?>(
+      user.value!.profile.birthday?.toLocal(),
+    );
     final firstNameController = useTextEditingController(
       text: user.value!.profile.firstName,
     );
@@ -164,7 +166,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
             'pronouns': pronounsController.text,
             'location': locationController.text,
             'time_zone': timeZoneController.text,
-            'birthday': birthday.value?.toIso8601String(),
+            'birthday': birthday.value?.toUtc().toIso8601String(),
           },
         );
         final userNotifier = ref.read(userInfoProvider.notifier);
diff --git a/lib/screens/account/profile.dart b/lib/screens/account/profile.dart
index e35e6d2..9133d6b 100644
--- a/lib/screens/account/profile.dart
+++ b/lib/screens/account/profile.dart
@@ -9,6 +9,8 @@ import 'package:island/pods/event_calendar.dart';
 import 'package:island/pods/network.dart';
 import 'package:island/pods/userinfo.dart';
 import 'package:island/services/color.dart';
+import 'package:island/services/time.dart';
+import 'package:island/services/timezone/native.dart';
 import 'package:island/widgets/account/account_name.dart';
 import 'package:island/widgets/account/badge.dart';
 import 'package:island/widgets/account/fortune_graph.dart';
@@ -16,6 +18,7 @@ import 'package:island/widgets/account/leveling_progress.dart';
 import 'package:island/widgets/account/status.dart';
 import 'package:island/widgets/app_scaffold.dart';
 import 'package:island/widgets/content/cloud_files.dart';
+import 'package:material_symbols_icons/symbols.dart';
 import 'package:palette_generator/palette_generator.dart';
 import 'package:riverpod_annotation/riverpod_annotation.dart';
 import 'package:styled_widget/styled_widget.dart';
@@ -191,13 +194,115 @@ class AccountProfileScreen extends HookConsumerWidget {
                 SliverToBoxAdapter(
                   child: Column(
                     crossAxisAlignment: CrossAxisAlignment.stretch,
+                    spacing: 24,
                     children: [
-                      Text('bio').tr().bold(),
-                      Text(
-                        data.profile.bio.isEmpty
-                            ? 'descriptionNone'.tr()
-                            : data.profile.bio,
+                      Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        spacing: 2,
+                        children: [
+                          if (data.profile.birthday != null)
+                            Row(
+                              spacing: 6,
+                              children: [
+                                const Icon(Symbols.cake, size: 17, fill: 1),
+                                Text(
+                                  data.profile.birthday!.formatCustom(
+                                    'yyyy-MM-dd',
+                                  ),
+                                ),
+                                Text('·').bold(),
+                                Text(
+                                  '${DateTime.now().difference(data.profile.birthday!).inDays ~/ 365} yrs old',
+                                ),
+                              ],
+                            ),
+                          if (data.profile.location.isNotEmpty)
+                            Row(
+                              spacing: 6,
+                              children: [
+                                const Icon(
+                                  Symbols.location_on,
+                                  size: 17,
+                                  fill: 1,
+                                ),
+                                Text(data.profile.location),
+                              ],
+                            ),
+                          if (data.profile.pronouns.isNotEmpty ||
+                              data.profile.gender.isNotEmpty)
+                            Row(
+                              spacing: 6,
+                              children: [
+                                const Icon(Symbols.person, size: 17, fill: 1),
+                                Text(
+                                  data.profile.gender.isEmpty
+                                      ? 'unspecified'.tr()
+                                      : data.profile.gender,
+                                ),
+                                Text('·').bold(),
+                                Text(
+                                  data.profile.pronouns.isEmpty
+                                      ? 'unspecified'.tr()
+                                      : data.profile.pronouns,
+                                ),
+                              ],
+                            ),
+                          if (data.profile.firstName.isNotEmpty ||
+                              data.profile.middleName.isNotEmpty ||
+                              data.profile.lastName.isNotEmpty)
+                            Row(
+                              spacing: 6,
+                              children: [
+                                const Icon(Symbols.id_card, size: 17, fill: 1),
+                                if (data.profile.firstName.isNotEmpty)
+                                  Text(data.profile.firstName),
+                                if (data.profile.middleName.isNotEmpty)
+                                  Text(data.profile.middleName),
+                                if (data.profile.lastName.isNotEmpty)
+                                  Text(data.profile.lastName),
+                              ],
+                            ),
+                        ],
                       ),
+                      Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Text('bio').tr().bold(),
+                          Text(
+                            data.profile.bio.isEmpty
+                                ? 'descriptionNone'.tr()
+                                : data.profile.bio,
+                          ),
+                        ],
+                      ),
+                      if (data.profile.timeZone.isNotEmpty)
+                        Column(
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            Text('timeZone').tr().bold(),
+                            Row(
+                              crossAxisAlignment: CrossAxisAlignment.baseline,
+                              textBaseline: TextBaseline.alphabetic,
+                              spacing: 6,
+                              children: [
+                                Text(data.profile.timeZone),
+                                Text(
+                                  getTzInfo(
+                                    data.profile.timeZone,
+                                  ).$2.formatCustomGlobal('HH:mm'),
+                                ),
+                                Text(
+                                  getTzInfo(
+                                    data.profile.timeZone,
+                                  ).$1.formatOffsetLocal(),
+                                ).fontSize(11),
+                                Text(
+                                  'UTC${getTzInfo(data.profile.timeZone).$1.formatOffset()}',
+                                ).fontSize(11).opacity(0.75),
+                              ],
+                            ),
+                          ],
+                        ),
                     ],
                   ).padding(horizontal: 24),
                 ),
diff --git a/lib/screens/explore.g.dart b/lib/screens/explore.g.dart
index bfda7ef..fa9bdb1 100644
--- a/lib/screens/explore.g.dart
+++ b/lib/screens/explore.g.dart
@@ -7,7 +7,7 @@ part of 'explore.dart';
 // **************************************************************************
 
 String _$activityListNotifierHash() =>
-    r'1ac57c33c7a65700116a9698209f52cd4be932cc';
+    r'c9683035f7a66a2f331689e274642b60064fbb2e';
 
 /// See also [ActivityListNotifier].
 @ProviderFor(ActivityListNotifier)
diff --git a/lib/services/time.dart b/lib/services/time.dart
index 6e61381..79f1a34 100644
--- a/lib/services/time.dart
+++ b/lib/services/time.dart
@@ -19,6 +19,38 @@ extension DurationFormatter on Duration {
 
     return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
   }
+
+  String formatOffset() {
+    final isNegative = inMicroseconds < 0;
+    final positiveDuration = isNegative ? -this : this;
+
+    final hours = positiveDuration.inHours.toString().padLeft(2, '0');
+    final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
+      2,
+      '0',
+    );
+
+    return '${isNegative ? '-' : '+'}$hours:$minutes';
+  }
+
+  String formatOffsetLocal() {
+    // Get the local timezone offset
+    final localOffset = DateTime.now().timeZoneOffset;
+
+    // Add the local offset to the input duration
+    final totalOffset = this - localOffset;
+
+    final isNegative = totalOffset.inMicroseconds < 0;
+    final positiveDuration = isNegative ? -totalOffset : totalOffset;
+
+    final hours = positiveDuration.inHours.toString().padLeft(2, '0');
+    final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
+      2,
+      '0',
+    );
+
+    return '${isNegative ? '-' : '+'}$hours:$minutes';
+  }
 }
 
 extension DateTimeFormatter on DateTime {
@@ -30,6 +62,10 @@ extension DateTimeFormatter on DateTime {
     return DateFormat(pattern).format(toLocal());
   }
 
+  String formatCustomGlobal(String pattern) {
+    return DateFormat(pattern).format(this);
+  }
+
   String formatWithLocale(String locale) {
     return DateFormat.yMd().add_jm().format(toLocal()).toString();
   }
diff --git a/lib/widgets/account/account_nameplate.dart b/lib/widgets/account/account_nameplate.dart
index 30be239..93582b6 100644
--- a/lib/widgets/account/account_nameplate.dart
+++ b/lib/widgets/account/account_nameplate.dart
@@ -66,8 +66,8 @@ class AccountNameplate extends HookConsumerWidget {
                                     begin: Alignment.bottomCenter,
                                     end: Alignment.topCenter,
                                     colors: [
-                                      Colors.black.withOpacity(0.7),
-                                      Colors.black.withOpacity(0.3),
+                                      Colors.black.withOpacity(0.8),
+                                      Colors.black.withOpacity(0.1),
                                       Colors.transparent,
                                     ],
                                   ),
diff --git a/lib/widgets/account/account_pfc.dart b/lib/widgets/account/account_pfc.dart
index 14b1129..5a1781c 100644
--- a/lib/widgets/account/account_pfc.dart
+++ b/lib/widgets/account/account_pfc.dart
@@ -7,6 +7,8 @@ import 'package:flutter_popup_card/flutter_popup_card.dart';
 import 'package:gap/gap.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:island/screens/account/profile.dart';
+import 'package:island/services/time.dart';
+import 'package:island/services/timezone/native.dart';
 import 'package:island/widgets/account/account_name.dart';
 import 'package:island/widgets/account/badge.dart';
 import 'package:island/widgets/account/leveling_progress.dart';
@@ -72,6 +74,27 @@ class AccountProfileCard extends HookConsumerWidget {
                         uname: data.name,
                         padding: EdgeInsets.zero,
                       ),
+                      if (data.profile.timeZone.isNotEmpty)
+                        Row(
+                          spacing: 6,
+                          children: [
+                            Icon(
+                              Symbols.alarm,
+                              size: 17,
+                              fill: 1,
+                            ).padding(right: 2),
+                            Text(
+                              getTzInfo(
+                                data.profile.timeZone,
+                              ).$2.formatCustomGlobal('HH:mm'),
+                            ).fontSize(12),
+                            Text(
+                              getTzInfo(
+                                data.profile.timeZone,
+                              ).$1.formatOffsetLocal(),
+                            ).fontSize(12),
+                          ],
+                        ).padding(top: 2),
                       if (data.badges.isNotEmpty)
                         BadgeList(badges: data.badges).padding(top: 12),
                       LevelingProgressCard(