From 610b924daf49c38456df553659aca860a4dc2b1d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 13 May 2025 00:36:48 +0800 Subject: [PATCH] :sparkles: Badges --- assets/i18n/en-US.json | 26 +++- lib/models/badge.dart | 104 ++++++++++++++ lib/models/user.dart | 20 +++ lib/models/user.freezed.dart | 203 ++++++++++++++++++++++++++-- lib/models/user.g.dart | 40 ++++++ lib/screens/account.dart | 42 +++--- lib/screens/account/me/update.dart | 209 +++++++++++++++-------------- lib/screens/account/profile.dart | 84 +++++++++--- lib/widgets/account/badge.dart | 46 +++++++ lib/widgets/account/status.dart | 11 +- 10 files changed, 624 insertions(+), 161 deletions(-) create mode 100644 lib/models/badge.dart create mode 100644 lib/widgets/account/badge.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index a7d8f52..8cc8880 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -182,5 +182,29 @@ "uploadAll": "Upload All", "stickerCopyPlaceholder": "Copy Placeholder", "realmSelection": "Select a Realm", - "publisherIndividual": "Individual Publishers" + "publisherIndividual": "Individual Publishers", + "firstPostBadgeName": "First Post", + "firstPostBadgeDescription": "Created your first post on Solar Network", + "popularPostBadgeName": "Popular Post", + "popularPostBadgeDescription": "Your post received significant engagement from the community", + "viralPostBadgeName": "Viral Post", + "viralPostBadgeDescription": "Your post went viral and reached a wide audience", + "helpfulCommentBadgeName": "Helpful Comment", + "helpfulCommentBadgeDescription": "Your comment was marked as helpful by others", + "newcomerBadgeName": "Newcomer", + "newcomerBadgeDescription": "Welcome to Solar Network! Start exploring and connecting", + "contributorBadgeName": "Contributor", + "contributorBadgeDescription": "Actively contributing to the Solar Network community", + "expertBadgeName": "Expert", + "expertBadgeDescription": "Recognized for your expertise and valuable contributions", + "founderBadgeName": "Founder", + "founderBadgeDescription": "One of the earliest members of Solar Network", + "betaTesterBadgeName": "Beta Tester", + "betaTesterBadgeDescription": "Helped test and improve Solar Network during beta", + "moderatorBadgeName": "Moderator", + "moderatorBadgeDescription": "Helping maintain and moderate the community", + "developerBadgeName": "Developer", + "developerBadgeDescription": "Contributing to Solar Network's development", + "translatorBadgeName": "Translator", + "translatorBadgeDescription": "Helping translate Solar Network into different languages" } diff --git a/lib/models/badge.dart b/lib/models/badge.dart new file mode 100644 index 0000000..d77ac73 --- /dev/null +++ b/lib/models/badge.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +class BadgeInfo { + final String type; + final String name; + final String description; + final IconData icon; + final Color color; + + const BadgeInfo({ + required this.type, + required this.name, + required this.description, + this.icon = Icons.star, + this.color = Colors.blue, + }); +} + +const Map kBadgeTemplates = { + 'achievements.post.first': BadgeInfo( + type: 'achievements.post.first', + name: 'firstPostBadgeName', + description: 'firstPostBadgeDescription', + icon: Icons.create, + color: Colors.green, + ), + 'achievements.post.popular': BadgeInfo( + type: 'achievements.post.popular', + name: 'popularPostBadgeName', + description: 'popularPostBadgeDescription', + icon: Icons.trending_up, + color: Colors.orange, + ), + 'achievements.post.viral': BadgeInfo( + type: 'achievements.post.viral', + name: 'viralPostBadgeName', + description: 'viralPostBadgeDescription', + icon: Icons.whatshot, + color: Colors.red, + ), + 'achievements.comment.helpful': BadgeInfo( + type: 'achievements.comment.helpful', + name: 'helpfulCommentBadgeName', + description: 'helpfulCommentBadgeDescription', + icon: Icons.thumb_up, + color: Colors.lightBlue, + ), + 'ranks.newcomer': BadgeInfo( + type: 'ranks.newcomer', + name: 'newcomerBadgeName', + description: 'newcomerBadgeDescription', + icon: Icons.person_outline, + color: Colors.blue, + ), + 'ranks.contributor': BadgeInfo( + type: 'ranks.contributor', + name: 'contributorBadgeName', + description: 'contributorBadgeDescription', + icon: Icons.stars, + color: Colors.purple, + ), + 'ranks.expert': BadgeInfo( + type: 'ranks.expert', + name: 'expertBadgeName', + description: 'expertBadgeDescription', + icon: Icons.workspace_premium, + color: Colors.amber, + ), + 'event.founder': BadgeInfo( + type: 'event.founder', + name: 'founderBadgeName', + description: 'founderBadgeDescription', + icon: Icons.foundation, + color: Colors.deepPurple, + ), + 'event.beta.tester': BadgeInfo( + type: 'event.beta.tester', + name: 'betaTesterBadgeName', + description: 'betaTesterBadgeDescription', + icon: Icons.bug_report, + color: Colors.teal, + ), + 'special.moderator': BadgeInfo( + type: 'special.moderator', + name: 'moderatorBadgeName', + description: 'moderatorBadgeDescription', + icon: Icons.construction, + color: Colors.indigo, + ), + 'special.developer': BadgeInfo( + type: 'special.developer', + name: 'developerBadgeName', + description: 'developerBadgeDescription', + icon: Icons.code, + color: Colors.indigo, + ), + 'special.translator': BadgeInfo( + type: 'special.translator', + name: 'translatorBadgeName', + description: 'translatorBadgeDescription', + icon: Icons.code, + color: Colors.grey, + ), +}; diff --git a/lib/models/user.dart b/lib/models/user.dart index 2e30b42..f696ece 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -13,6 +13,7 @@ abstract class SnAccount with _$SnAccount { required String language, required bool isSuperuser, required SnAccountProfile profile, + @Default([]) List badges, required DateTime createdAt, required DateTime updatedAt, required DateTime? deletedAt, @@ -63,3 +64,22 @@ abstract class SnAccountStatus with _$SnAccountStatus { factory SnAccountStatus.fromJson(Map json) => _$SnAccountStatusFromJson(json); } + +@freezed +abstract class SnAccountBadge with _$SnAccountBadge { + const factory SnAccountBadge({ + required String id, + required String type, + required String? label, + required String? caption, + required Map meta, + required DateTime? expiredAt, + required int accountId, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAccountBadge; + + factory SnAccountBadge.fromJson(Map json) => + _$SnAccountBadgeFromJson(json); +} diff --git a/lib/models/user.freezed.dart b/lib/models/user.freezed.dart index ddf7701..d11215b 100644 --- a/lib/models/user.freezed.dart +++ b/lib/models/user.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SnAccount { - int get id; String get name; String get nick; String get language; bool get isSuperuser; SnAccountProfile get profile; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; + int get id; String get name; String get nick; String get language; bool get isSuperuser; SnAccountProfile get profile; List get badges; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; /// Create a copy of SnAccount /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -29,16 +29,16 @@ $SnAccountCopyWith get copyWith => _$SnAccountCopyWithImpl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&const DeepCollectionEquality().equals(other.badges, badges)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,const DeepCollectionEquality().hash(badges),createdAt,updatedAt,deletedAt); @override String toString() { - return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, badges: $badges, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -49,7 +49,7 @@ abstract mixin class $SnAccountCopyWith<$Res> { factory $SnAccountCopyWith(SnAccount value, $Res Function(SnAccount) _then) = _$SnAccountCopyWithImpl; @useResult $Res call({ - int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, List badges, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -66,7 +66,7 @@ class _$SnAccountCopyWithImpl<$Res> /// Create a copy of SnAccount /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? badges = null,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 int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -74,7 +74,8 @@ as String,nick: null == nick ? _self.nick : nick // ignore: cast_nullable_to_non as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable as String,isSuperuser: null == isSuperuser ? _self.isSuperuser : isSuperuser // ignore: cast_nullable_to_non_nullable as bool,profile: null == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable -as SnAccountProfile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as SnAccountProfile,badges: null == badges ? _self.badges : badges // ignore: cast_nullable_to_non_nullable +as List,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?, @@ -97,7 +98,7 @@ $SnAccountProfileCopyWith<$Res> get profile { @JsonSerializable() class _SnAccount implements SnAccount { - const _SnAccount({required this.id, required this.name, required this.nick, required this.language, required this.isSuperuser, required this.profile, required this.createdAt, required this.updatedAt, required this.deletedAt}); + const _SnAccount({required this.id, required this.name, required this.nick, required this.language, required this.isSuperuser, required this.profile, final List badges = const [], required this.createdAt, required this.updatedAt, required this.deletedAt}): _badges = badges; factory _SnAccount.fromJson(Map json) => _$SnAccountFromJson(json); @override final int id; @@ -106,6 +107,13 @@ class _SnAccount implements SnAccount { @override final String language; @override final bool isSuperuser; @override final SnAccountProfile profile; + final List _badges; +@override@JsonKey() List get badges { + if (_badges is EqualUnmodifiableListView) return _badges; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_badges); +} + @override final DateTime createdAt; @override final DateTime updatedAt; @override final DateTime? deletedAt; @@ -123,16 +131,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccount&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.language, language) || other.language == language)&&(identical(other.isSuperuser, isSuperuser) || other.isSuperuser == isSuperuser)&&(identical(other.profile, profile) || other.profile == profile)&&const DeepCollectionEquality().equals(other._badges, _badges)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,createdAt,updatedAt,deletedAt); +int get hashCode => Object.hash(runtimeType,id,name,nick,language,isSuperuser,profile,const DeepCollectionEquality().hash(_badges),createdAt,updatedAt,deletedAt); @override String toString() { - return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; + return 'SnAccount(id: $id, name: $name, nick: $nick, language: $language, isSuperuser: $isSuperuser, profile: $profile, badges: $badges, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; } @@ -143,7 +151,7 @@ abstract mixin class _$SnAccountCopyWith<$Res> implements $SnAccountCopyWith<$Re factory _$SnAccountCopyWith(_SnAccount value, $Res Function(_SnAccount) _then) = __$SnAccountCopyWithImpl; @override @useResult $Res call({ - int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt + int id, String name, String nick, String language, bool isSuperuser, SnAccountProfile profile, List badges, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt }); @@ -160,7 +168,7 @@ class __$SnAccountCopyWithImpl<$Res> /// Create a copy of SnAccount /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? nick = null,Object? language = null,Object? isSuperuser = null,Object? profile = null,Object? badges = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { return _then(_SnAccount( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as int,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable @@ -168,7 +176,8 @@ as String,nick: null == nick ? _self.nick : nick // ignore: cast_nullable_to_non as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable as String,isSuperuser: null == isSuperuser ? _self.isSuperuser : isSuperuser // ignore: cast_nullable_to_non_nullable as bool,profile: null == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable -as SnAccountProfile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as SnAccountProfile,badges: null == badges ? _self._badges : badges // ignore: cast_nullable_to_non_nullable +as List,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?, @@ -565,6 +574,172 @@ as DateTime?, } +} + + +/// @nodoc +mixin _$SnAccountBadge { + + String get id; String get type; String? get label; String? get caption; Map get meta; DateTime? get expiredAt; int get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAccountBadge +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAccountBadgeCopyWith get copyWith => _$SnAccountBadgeCopyWithImpl(this as SnAccountBadge, _$identity); + + /// Serializes this SnAccountBadge to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(meta),expiredAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAccountBadgeCopyWith<$Res> { + factory $SnAccountBadgeCopyWith(SnAccountBadge value, $Res Function(SnAccountBadge) _then) = _$SnAccountBadgeCopyWithImpl; +@useResult +$Res call({ + String id, String type, String? label, String? caption, Map meta, DateTime? expiredAt, int accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnAccountBadgeCopyWithImpl<$Res> + implements $SnAccountBadgeCopyWith<$Res> { + _$SnAccountBadgeCopyWithImpl(this._self, this._then); + + final SnAccountBadge _self; + final $Res Function(SnAccountBadge) _then; + +/// Create a copy of SnAccountBadge +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable +as String?,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable +as Map,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAccountBadge implements SnAccountBadge { + const _SnAccountBadge({required this.id, required this.type, required this.label, required this.caption, required final Map meta, required this.expiredAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; + factory _SnAccountBadge.fromJson(Map json) => _$SnAccountBadgeFromJson(json); + +@override final String id; +@override final String type; +@override final String? label; +@override final String? caption; + final Map _meta; +@override Map get meta { + if (_meta is EqualUnmodifiableMapView) return _meta; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_meta); +} + +@override final DateTime? expiredAt; +@override final int accountId; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAccountBadge +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAccountBadgeCopyWith<_SnAccountBadge> get copyWith => __$SnAccountBadgeCopyWithImpl<_SnAccountBadge>(this, _$identity); + +@override +Map toJson() { + return _$SnAccountBadgeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountBadge&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.label, label) || other.label == label)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,type,label,caption,const DeepCollectionEquality().hash(_meta),expiredAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountBadge(id: $id, type: $type, label: $label, caption: $caption, meta: $meta, expiredAt: $expiredAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAccountBadgeCopyWith<$Res> implements $SnAccountBadgeCopyWith<$Res> { + factory _$SnAccountBadgeCopyWith(_SnAccountBadge value, $Res Function(_SnAccountBadge) _then) = __$SnAccountBadgeCopyWithImpl; +@override @useResult +$Res call({ + String id, String type, String? label, String? caption, Map meta, DateTime? expiredAt, int accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnAccountBadgeCopyWithImpl<$Res> + implements _$SnAccountBadgeCopyWith<$Res> { + __$SnAccountBadgeCopyWithImpl(this._self, this._then); + + final _SnAccountBadge _self; + final $Res Function(_SnAccountBadge) _then; + +/// Create a copy of SnAccountBadge +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? label = freezed,Object? caption = freezed,Object? meta = null,Object? expiredAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAccountBadge( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable +as String?,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable +as Map,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable +as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable +as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + } // dart format on diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index f8756bd..c785488 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -13,6 +13,11 @@ _SnAccount _$SnAccountFromJson(Map json) => _SnAccount( language: json['language'] as String, isSuperuser: json['is_superuser'] as bool, profile: SnAccountProfile.fromJson(json['profile'] as Map), + badges: + (json['badges'] as List?) + ?.map((e) => SnAccountBadge.fromJson(e as Map)) + .toList() ?? + const [], createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), deletedAt: @@ -29,6 +34,7 @@ Map _$SnAccountToJson(_SnAccount instance) => 'language': instance.language, 'is_superuser': instance.isSuperuser, 'profile': instance.profile.toJson(), + 'badges': instance.badges.map((e) => e.toJson()).toList(), 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), @@ -114,3 +120,37 @@ Map _$SnAccountStatusToJson(_SnAccountStatus instance) => 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), }; + +_SnAccountBadge _$SnAccountBadgeFromJson(Map json) => + _SnAccountBadge( + id: json['id'] as String, + type: json['type'] as String, + label: json['label'] as String?, + caption: json['caption'] as String?, + meta: json['meta'] as Map, + expiredAt: + json['expired_at'] == null + ? null + : DateTime.parse(json['expired_at'] as String), + accountId: (json['account_id'] as num).toInt(), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: + json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); + +Map _$SnAccountBadgeToJson(_SnAccountBadge instance) => + { + 'id': instance.id, + 'type': instance.type, + 'label': instance.label, + 'caption': instance.caption, + 'meta': instance.meta, + 'expired_at': instance.expiredAt?.toIso8601String(), + 'account_id': instance.accountId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 14280f5..77488d2 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -36,7 +36,7 @@ class AccountScreen extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (user.value?.profile.background != null) + if (user.value?.profile.backgroundId != null) ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(8), @@ -44,8 +44,8 @@ class AccountScreen extends HookConsumerWidget { ), child: AspectRatio( aspectRatio: 16 / 7, - child: CloudFileWidget( - item: user.value!.profile.background!, + child: CloudImageWidget( + fileId: user.value!.profile.backgroundId!, fit: BoxFit.cover, ), ), @@ -65,22 +65,26 @@ class AccountScreen extends HookConsumerWidget { ); }, ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(user.value!.nick).bold().fontSize(16), - Text('@${user.value!.name}'), - ], - ), - Text( - user.value!.profile.bio ?? 'No description yet.', - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(user.value!.nick).bold().fontSize(16), + Text('@${user.value!.name}'), + ], + ), + Text( + user.value!.profile.bio ?? 'No description yet.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ], ).padding(horizontal: 16, top: 16), diff --git a/lib/screens/account/me/update.dart b/lib/screens/account/me/update.dart index c275457..9722d5c 100644 --- a/lib/screens/account/me/update.dart +++ b/lib/screens/account/me/update.dart @@ -143,116 +143,119 @@ class UpdateProfileScreen extends HookConsumerWidget { title: Text('updateYourProfile').tr(), leading: const PageBackButton(), ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 16 / 7, - child: Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, - children: [ - GestureDetector( - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: - user.value!.profile.background != null - ? CloudFileWidget( - item: user.value!.profile.background!, - fit: BoxFit.cover, - ) - : const SizedBox.shrink(), - ), - onTap: () { - updateProfilePicture('background'); - }, - ), - Positioned( - left: 20, - bottom: -32, - child: GestureDetector( - child: ProfilePictureWidget( - fileId: user.value!.profile.pictureId, - radius: 40, + body: SingleChildScrollView( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 7, + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.expand, + children: [ + GestureDetector( + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: + user.value!.profile.background != null + ? CloudFileWidget( + item: user.value!.profile.background!, + fit: BoxFit.cover, + ) + : const SizedBox.shrink(), ), onTap: () { - updateProfilePicture('picture'); + updateProfilePicture('background'); }, ), - ), - ], + Positioned( + left: 20, + bottom: -32, + child: GestureDetector( + child: ProfilePictureWidget( + fileId: user.value!.profile.pictureId, + radius: 40, + ), + onTap: () { + updateProfilePicture('picture'); + }, + ), + ), + ], + ), + ).padding(bottom: 32), + Text('accountBasicInfo') + .tr() + .bold() + .fontSize(18) + .padding(horizontal: 24, top: 16, bottom: 12), + Form( + key: formKeyBasicInfo, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + TextFormField( + decoration: InputDecoration( + labelText: 'username'.tr(), + helperText: 'usernameCannotChangeHint'.tr(), + prefixText: '@', + ), + controller: usernameController, + readOnly: true, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + TextFormField( + decoration: InputDecoration(labelText: 'nickname'.tr()), + controller: nicknameController, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: submitting.value ? null : updateBasicInfo, + label: Text('saveChanges').tr(), + icon: const Icon(Symbols.save), + ), + ), + ], + ).padding(horizontal: 24), ), - ).padding(bottom: 32), - Text('accountBasicInfo') - .tr() - .bold() - .fontSize(18) - .padding(horizontal: 24, top: 16, bottom: 12), - Form( - key: formKeyBasicInfo, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - TextFormField( - decoration: InputDecoration( - labelText: 'username'.tr(), - helperText: 'usernameCannotChangeHint'.tr(), - prefixText: '@', + Text('accountProfile') + .tr() + .bold() + .fontSize(18) + .padding(horizontal: 24, top: 16, bottom: 8), + Form( + key: formKeyProfile, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + TextFormField( + decoration: InputDecoration(labelText: 'bio'.tr()), + maxLines: null, + minLines: 3, + controller: bioController, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), ), - controller: usernameController, - readOnly: true, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - TextFormField( - decoration: InputDecoration(labelText: 'nickname'.tr()), - controller: nicknameController, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: submitting.value ? null : updateBasicInfo, - label: Text('saveChanges').tr(), - icon: const Icon(Symbols.save), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: submitting.value ? null : updateProfile, + label: Text('saveChanges').tr(), + icon: const Icon(Symbols.save), + ), ), - ), - ], - ).padding(horizontal: 24), - ), - Text('accountProfile') - .tr() - .bold() - .fontSize(18) - .padding(horizontal: 24, top: 16, bottom: 8), - Form( - key: formKeyProfile, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - TextFormField( - decoration: InputDecoration(labelText: 'bio'.tr()), - maxLines: null, - minLines: 3, - controller: bioController, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: submitting.value ? null : updateProfile, - label: Text('saveChanges').tr(), - icon: const Icon(Symbols.save), - ), - ), - ], - ).padding(horizontal: 24), - ), - ], + ], + ).padding(horizontal: 24), + ), + ], + ), ), ); } diff --git a/lib/screens/account/profile.dart b/lib/screens/account/profile.dart index c2c2bbf..bab682c 100644 --- a/lib/screens/account/profile.dart +++ b/lib/screens/account/profile.dart @@ -1,12 +1,16 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/user.dart'; import 'package:island/pods/network.dart'; +import 'package:island/widgets/account/badge.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:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; part 'profile.g.dart'; @@ -28,6 +32,13 @@ class AccountProfileScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accountAsync = ref.watch(accountProvider(name)); + + final iconShadow = Shadow( + color: Colors.black54, + blurRadius: 5.0, + offset: Offset(1.0, 1.0), + ); + return accountAsync.when( data: (data) => AppScaffold( @@ -36,6 +47,7 @@ class AccountProfileScreen extends HookConsumerWidget { SliverAppBar( expandedHeight: 180, pinned: true, + leading: PageBackButton(shadows: [iconShadow]), flexibleSpace: FlexibleSpaceBar( background: data.profile.backgroundId != null @@ -47,33 +59,67 @@ class AccountProfileScreen extends HookConsumerWidget { Theme.of(context).appBarTheme.backgroundColor, ), title: Text( - data.name, + data.nick, style: TextStyle( color: Theme.of(context).appBarTheme.foregroundColor, - shadows: [ - Shadow( - color: Colors.black54, - blurRadius: 5.0, - offset: Offset(1.0, 1.0), - ), - ], + shadows: [iconShadow], ), ), ), ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.profile.bio ?? '', - style: const TextStyle(fontSize: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 20, + children: [ + ProfilePictureWidget( + fileId: data.profile.pictureId!, + radius: 32, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + spacing: 6, + children: [ + Text(data.nick).fontSize(20), + Text( + '@${data.name}', + ).fontSize(14).opacity(0.85), + ], + ), + AccountStatusWidget( + uname: name, + padding: EdgeInsets.zero, + ), + ], ), - ], - ), - ), + ), + ], + ).padding(horizontal: 24, top: 24, bottom: 8), + ), + if (data.badges.isNotEmpty) + SliverToBoxAdapter( + child: BadgeList( + badges: data.badges, + ).padding(horizontal: 24, bottom: 24), + ) + else + const Gap(16), + SliverToBoxAdapter( + child: const Divider(height: 1).padding(bottom: 24), + ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('bio').tr().bold(), + if (data.profile.bio != null && + data.profile.bio!.isNotEmpty) + Text(data.profile.bio!), + ], + ).padding(horizontal: 24), ), ], ), diff --git a/lib/widgets/account/badge.dart b/lib/widgets/account/badge.dart new file mode 100644 index 0000000..8fe5ccd --- /dev/null +++ b/lib/widgets/account/badge.dart @@ -0,0 +1,46 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:island/models/user.dart'; +import 'package:island/models/badge.dart'; + +class BadgeList extends StatelessWidget { + final List badges; + const BadgeList({super.key, required this.badges}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: badges.map((badge) => BadgeItem(badge: badge)).toList(), + ); + } +} + +class BadgeItem extends StatelessWidget { + final SnAccountBadge badge; + const BadgeItem({super.key, required this.badge}); + + @override + Widget build(BuildContext context) { + final template = kBadgeTemplates[badge.type]; + final name = badge.label ?? template?.name.tr() ?? 'unknown'.tr(); + final description = badge.caption ?? template?.description.tr() ?? ''; + + return Tooltip( + message: '$name\n$description', + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: (template?.color ?? Colors.blue).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + template?.icon ?? Icons.stars, + color: template?.color ?? Colors.orange, + size: 20, + ), + ), + ); + } +} diff --git a/lib/widgets/account/status.dart b/lib/widgets/account/status.dart index 074b078..41d1b9c 100644 --- a/lib/widgets/account/status.dart +++ b/lib/widgets/account/status.dart @@ -50,7 +50,10 @@ class AccountStatusCreationWidget extends HookConsumerWidget { data: (status) => (status?.isCustomized ?? false) - ? AccountStatusWidget(uname: uname) + ? Padding( + padding: const EdgeInsets.only(left: 4), + child: AccountStatusWidget(uname: uname), + ) : Padding( padding: padding ?? @@ -112,15 +115,13 @@ class AccountStatusWidget extends HookConsumerWidget { child: Row( spacing: 4, children: [ - if (!(userStatus.value?.isCustomized ?? false)) - Icon(Symbols.keyboard_arrow_up) - else if (userStatus.value!.isOnline) + if (userStatus.value!.isOnline) Icon( Symbols.circle, fill: 1, color: Colors.green, size: 16, - ).padding(all: 4) + ).padding(right: 4) else Icon(Symbols.circle, color: Colors.grey, size: 16).padding(all: 4), if (userStatus.value?.isCustomized ?? false)