Compare commits

..

9 Commits

Author SHA1 Message Date
6b3338b885 🚀 Launch 3.1.0+118 2025-08-09 00:48:50 +08:00
bb00b1bc6a Debug options 2025-08-09 00:44:37 +08:00
5e1a15ada2 Show profile links 2025-08-09 00:27:43 +08:00
9bdf8ba346 🐛 Fix bugs 2025-08-09 00:21:27 +08:00
204c087f29 Edit profile links 2025-08-09 00:15:35 +08:00
1def3e1895 💄 Optimize profile page 2025-08-08 23:46:40 +08:00
550c74e544 🐛 Fix unable to subscribe 2025-08-08 23:23:16 +08:00
a39565f012 Post category details 2025-08-08 23:20:22 +08:00
aa9755e6a7 💄 Optimized post category and tag 2025-08-08 22:19:17 +08:00
18 changed files with 1049 additions and 342 deletions

View File

@@ -782,5 +782,10 @@
"postCategoryStudy": "Study",
"postCategoryGaming": "Gaming",
"postCategoryProgramming": "Programming",
"postCategoryMusic": "Music"
"postCategoryMusic": "Music",
"links": "Links",
"addLink": "Add link",
"linkKey": "Link Name",
"linkValue": "URL",
"debugOptions": "Debug Options"
}

View File

@@ -38,6 +38,7 @@ sealed class SnAccountProfile with _$SnAccountProfile {
@Default('') String location,
@Default('') String timeZone,
DateTime? birthday,
@Default({}) Map<String, String> links,
DateTime? lastSeenAt,
SnAccountBadge? activeBadge,
required int experience,

View File

@@ -350,7 +350,7 @@ $SnWalletSubscriptionRefCopyWith<$Res>? get perkSubscription {
/// @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; Map<String, String> get links; 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)
@@ -363,16 +363,16 @@ $SnAccountProfileCopyWith<SnAccountProfile> get copyWith => _$SnAccountProfileCo
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(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 SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other.links, links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(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.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(links),lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
@override
String toString() {
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -383,7 +383,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, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -400,7 +400,7 @@ 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 = 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,}) {
@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? links = null,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: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
@@ -412,7 +412,8 @@ as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast
as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable
as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable
as DateTime?,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable
as DateTime?,links: null == links ? _self.links : links // ignore: cast_nullable_to_non_nullable
as Map<String, String>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable
as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable
as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable
as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable
@@ -553,10 +554,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( 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)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnAccountProfile() when $default != null:
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
@@ -574,10 +575,10 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( 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) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnAccountProfile():
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);}
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -591,10 +592,10 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( 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)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnAccountProfile() when $default != null:
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
@@ -606,7 +607,7 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
@JsonSerializable()
class _SnAccountProfile implements SnAccountProfile {
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});
const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, final Map<String, String> links = const {}, 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}): _links = links;
factory _SnAccountProfile.fromJson(Map<String, dynamic> json) => _$SnAccountProfileFromJson(json);
@override final String id;
@@ -619,6 +620,13 @@ class _SnAccountProfile implements SnAccountProfile {
@override@JsonKey() final String location;
@override@JsonKey() final String timeZone;
@override final DateTime? birthday;
final Map<String, String> _links;
@override@JsonKey() Map<String, String> get links {
if (_links is EqualUnmodifiableMapView) return _links;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_links);
}
@override final DateTime? lastSeenAt;
@override final SnAccountBadge? activeBadge;
@override final int experience;
@@ -644,16 +652,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(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 _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other._links, _links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(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.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(_links),lastSeenAt,activeBadge,experience,level,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
@override
String toString() {
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -664,7 +672,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, Map<String, String> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -681,7 +689,7 @@ 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 = 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,}) {
@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? links = null,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: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
@@ -693,7 +701,8 @@ as String,pronouns: null == pronouns ? _self.pronouns : pronouns // ignore: cast
as String,location: null == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
as String,timeZone: null == timeZone ? _self.timeZone : timeZone // ignore: cast_nullable_to_non_nullable
as String,birthday: freezed == birthday ? _self.birthday : birthday // ignore: cast_nullable_to_non_nullable
as DateTime?,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable
as DateTime?,links: null == links ? _self._links : links // ignore: cast_nullable_to_non_nullable
as Map<String, String>,lastSeenAt: freezed == lastSeenAt ? _self.lastSeenAt : lastSeenAt // ignore: cast_nullable_to_non_nullable
as DateTime?,activeBadge: freezed == activeBadge ? _self.activeBadge : activeBadge // ignore: cast_nullable_to_non_nullable
as SnAccountBadge?,experience: null == experience ? _self.experience : experience // ignore: cast_nullable_to_non_nullable
as int,level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable

View File

@@ -62,6 +62,11 @@ _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
json['birthday'] == null
? null
: DateTime.parse(json['birthday'] as String),
links:
(json['links'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
const {},
lastSeenAt:
json['last_seen_at'] == null
? null
@@ -111,6 +116,7 @@ Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
'location': instance.location,
'time_zone': instance.timeZone,
'birthday': instance.birthday?.toIso8601String(),
'links': instance.links,
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'active_badge': instance.activeBadge?.toJson(),
'experience': instance.experience,

View File

@@ -7,6 +7,7 @@ import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/discovery/articles.dart';
import 'package:island/screens/posts/post_category_detail.dart';
import 'package:island/screens/posts/post_search.dart';
import 'package:island/widgets/app_wrapper.dart';
import 'package:island/screens/tabs.dart';
@@ -322,15 +323,6 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const AboutScreen(),
),
GoRoute(
name: 'reportDetail',
path: '/safety/reports/me/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return AbuseReportDetailScreen(reportId: id);
},
),
// Main tabs with TabsScreen shell
ShellRoute(
navigatorKey: _tabsShellKey,
@@ -357,6 +349,25 @@ final routerProvider = Provider<GoRouter>((ref) {
return PostDetailScreen(id: id);
},
),
GoRoute(
name: 'postCategoryDetail',
path: '/posts/categories/:slug',
builder: (context, state) {
final slug = state.pathParameters['slug']!;
return PostCategoryDetailScreen(slug: slug, isCategory: true);
},
),
GoRoute(
name: 'postTagDetail',
path: '/posts/tags/:slug',
builder: (context, state) {
final slug = state.pathParameters['slug']!;
return PostCategoryDetailScreen(
slug: slug,
isCategory: false,
);
},
),
GoRoute(
name: 'publisherProfile',
path: '/publishers/:name',
@@ -505,6 +516,14 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/safety/reports/me',
builder: (context, state) => const AbuseReportListScreen(),
),
GoRoute(
name: 'reportDetail',
path: '/safety/reports/me/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return AbuseReportDetailScreen(reportId: id);
},
),
],
),

View File

@@ -102,235 +102,243 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 24),
// App Icon and Name
CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary.withOpacity(
0.1,
),
child: Image.asset(
'assets/icons/icon.png',
width: 56,
height: 56,
),
),
const SizedBox(height: 16),
Text(
_packageInfo.appName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'aboutScreenVersionInfo'.tr(
args: [_packageInfo.version, _packageInfo.buildNumber],
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(height: 32),
// App Info Card
_buildSection(
context,
title: 'aboutScreenAppInfoSectionTitle'.tr(),
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildInfoItem(
context,
icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName,
),
_buildInfoItem(
context,
icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version,
),
_buildInfoItem(
context,
icon: Symbols.build,
label: 'aboutScreenBuildNumberLabel'.tr(),
value: _packageInfo.buildNumber,
),
],
),
if (_deviceInfo != null) const SizedBox(height: 16),
if (_deviceInfo != null)
_buildSection(
context,
title: 'Device Information',
children: [
_buildInfoItem(
context,
icon: Symbols.label,
label: 'aboutDeviceName'.tr(),
value: _deviceInfo?.data['name'],
const SizedBox(height: 24),
// App Icon and Name
CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary
.withOpacity(0.1),
child: Image.asset(
'assets/icons/icon.png',
width: 56,
height: 56,
),
_buildInfoItem(
context,
icon: Symbols.fingerprint,
label: 'aboutDeviceIdentifier'.tr(),
value: _deviceUdid ?? 'N/A',
copyable: true,
),
const SizedBox(height: 16),
Text(
_packageInfo.appName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
],
),
),
Text(
'aboutScreenVersionInfo'.tr(
args: [
_packageInfo.version,
_packageInfo.buildNumber,
],
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(height: 32),
const SizedBox(height: 16),
// Links Card
_buildSection(
context,
title: 'aboutScreenLinksSectionTitle'.tr(),
children: [
_buildListTile(
// App Info Card
_buildSection(
context,
icon: Symbols.system_update,
title: 'Check for updates',
onTap: () async {
// Fetch latest release and show the unified sheet
final svc = UpdateService();
// Reuse service fetch + compare to decide content
final release = await svc.fetchLatestRelease();
if (release != null) {
await svc.showUpdateSheet(context, release);
} else {
// Fallback: show a simple sheet indicating no info
// Use your SheetScaffold for consistent styling
// Show a minimal message
// ignore: use_build_context_synchronously
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
backgroundColor:
Theme.of(context).colorScheme.surface,
builder:
(_) => const SheetScaffold(
titleText: 'Update',
child: Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Unable to fetch release info at this time.',
title: 'aboutScreenAppInfoSectionTitle'.tr(),
children: [
_buildInfoItem(
context,
icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName,
),
_buildInfoItem(
context,
icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version,
),
_buildInfoItem(
context,
icon: Symbols.build,
label: 'aboutScreenBuildNumberLabel'.tr(),
value: _packageInfo.buildNumber,
),
],
),
if (_deviceInfo != null) const SizedBox(height: 16),
if (_deviceInfo != null)
_buildSection(
context,
title: 'Device Information',
children: [
_buildInfoItem(
context,
icon: Symbols.label,
label: 'aboutDeviceName'.tr(),
value: _deviceInfo?.data['name'],
),
_buildInfoItem(
context,
icon: Symbols.fingerprint,
label: 'aboutDeviceIdentifier'.tr(),
value: _deviceUdid ?? 'N/A',
copyable: true,
),
],
),
const SizedBox(height: 16),
// Links Card
_buildSection(
context,
title: 'aboutScreenLinksSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Symbols.system_update,
title: 'Check for updates',
onTap: () async {
// Fetch latest release and show the unified sheet
final svc = UpdateService();
// Reuse service fetch + compare to decide content
final release = await svc.fetchLatestRelease();
if (release != null) {
await svc.showUpdateSheet(context, release);
} else {
// Fallback: show a simple sheet indicating no info
// Use your SheetScaffold for consistent styling
// Show a minimal message
// ignore: use_build_context_synchronously
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
backgroundColor:
Theme.of(context).colorScheme.surface,
builder:
(_) => const SheetScaffold(
titleText: 'Update',
child: Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Unable to fetch release info at this time.',
),
),
),
),
),
),
);
}
},
),
_buildListTile(
context,
icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap:
() => _launchURL(
'https://solsynth.dev/terms/privacy-policy',
),
),
_buildListTile(
context,
icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap:
() => _launchURL(
'https://solsynth.dev/terms/user-agreement',
),
),
_buildListTile(
context,
icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () {
showLicensePage(
context: context,
applicationName: _packageInfo.appName,
applicationVersion:
'Version ${_packageInfo.version}',
);
},
),
],
),
const SizedBox(height: 16),
// Developer Info
_buildSection(
context,
title: 'aboutScreenDeveloperSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'),
),
_buildListTile(
context,
icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr(
args: [DateTime.now().year.toString()],
),
onTap:
() => _launchURL(
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
),
),
if (kIsWeb || !(Platform.isMacOS || Platform.isIOS))
_buildListTile(
context,
icon: Symbols.favorite,
title: 'donate'.tr(),
subtitle: 'donateDescription'.tr(),
onTap: () {
launchUrlString(
'https://afdian.com/@littlesheep',
);
},
),
],
),
const SizedBox(height: 32),
// Copyright
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
'aboutScreenCopyright'.tr(
args: [DateTime.now().year.toString()],
);
}
},
),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const Gap(1),
Text(
'aboutScreenMadeWith'.tr(),
textAlign: TextAlign.center,
).fontSize(10).opacity(0.8),
],
),
),
_buildListTile(
context,
icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap:
() => _launchURL(
'https://solsynth.dev/terms/privacy-policy',
),
),
_buildListTile(
context,
icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap:
() => _launchURL(
'https://solsynth.dev/terms/user-agreement',
),
),
_buildListTile(
context,
icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () {
showLicensePage(
context: context,
applicationName: _packageInfo.appName,
applicationVersion:
'Version ${_packageInfo.version}',
);
},
),
],
),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
const SizedBox(height: 16),
// Developer Info
_buildSection(
context,
title: 'aboutScreenDeveloperSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev',
onTap:
() => _launchURL('mailto:lily@solsynth.dev'),
),
_buildListTile(
context,
icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr(
args: [DateTime.now().year.toString()],
),
onTap:
() => _launchURL(
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
),
),
if (kIsWeb || !(Platform.isMacOS || Platform.isIOS))
_buildListTile(
context,
icon: Symbols.favorite,
title: 'donate'.tr(),
subtitle: 'donateDescription'.tr(),
onTap: () {
launchUrlString(
'https://afdian.com/@littlesheep',
);
},
),
],
),
const SizedBox(height: 32),
// Copyright
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
'aboutScreenCopyright'.tr(
args: [DateTime.now().year.toString()],
),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const Gap(1),
Text(
'aboutScreenMadeWith'.tr(),
textAlign: TextAlign.center,
).fontSize(10).opacity(0.8),
],
),
),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
),
),
);

View File

@@ -1,12 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/message.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
@@ -15,6 +11,7 @@ import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/account/leveling_progress.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/debug_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -276,30 +273,6 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('accountSettings');
},
),
if (kDebugMode) const Divider(height: 1).padding(vertical: 8),
if (kDebugMode)
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.copy_all),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Copy access token'),
onTap: () async {
final tk = ref.watch(tokenProvider);
Clipboard.setData(ClipboardData(text: tk!.token));
},
),
if (kDebugMode)
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Reset database'),
onTap: () async {
resetDatabase(ref);
},
),
const Divider(height: 1).padding(vertical: 8),
ListTile(
minTileHeight: 48,
@@ -311,6 +284,19 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('about');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.bug_report),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('debugOptions').tr(),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => DebugSheet(),
);
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.logout),

View File

@@ -3,6 +3,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
@@ -94,6 +95,11 @@ class UpdateProfileScreen extends HookConsumerWidget {
final usernameController = useTextEditingController(text: user.value!.name);
final nicknameController = useTextEditingController(text: user.value!.nick);
final language = useState(user.value!.language);
final links = useState<List<Map<String, String>>>(
user.value!.profile.links.entries
.map((e) => {'key': e.key, 'value': e.value})
.toList(),
);
void updateBasicInfo() async {
if (!formKeyBasicInfo.currentState!.validate()) return;
@@ -165,6 +171,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
'location': locationController.text,
'time_zone': timeZoneController.text,
'birthday': birthday.value?.toUtc().toIso8601String(),
'links': {for (var e in links.value) e['key']!: e['value']!},
},
);
final userNotifier = ref.read(userInfoProvider.notifier);
@@ -558,6 +565,69 @@ class UpdateProfileScreen extends HookConsumerWidget {
),
),
),
Text('links').tr().bold().fontSize(18).padding(top: 16),
Column(
spacing: 8,
children: [
for (var i = 0; i < links.value.length; i++)
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextFormField(
initialValue: links.value[i]['key'],
decoration: InputDecoration(
labelText: 'linkKey'.tr(),
isDense: true,
),
onChanged: (value) {
links.value[i]['key'] = value;
},
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
),
const Gap(8),
Expanded(
child: TextFormField(
initialValue: links.value[i]['value'],
decoration: InputDecoration(
labelText: 'linkValue'.tr(),
isDense: true,
),
onChanged: (value) {
links.value[i]['value'] = value;
},
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
),
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
links.value = List.from(links.value)
..removeAt(i);
},
),
],
),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
links.value = List.from(links.value)
..add({'key': '', 'value': ''});
},
label: Text('addLink').tr(),
icon: const Icon(Symbols.add),
).padding(top: 8),
),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(

View File

@@ -13,6 +13,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/text.dart';
import 'package:island/services/time.dart';
import 'package:island/services/timezone/native.dart';
import 'package:island/widgets/account/account_name.dart';
@@ -30,6 +31,7 @@ import 'package:palette_generator/palette_generator.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:share_plus/share_plus.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'profile.g.dart';
@@ -350,6 +352,28 @@ class AccountProfileScreen extends HookConsumerWidget {
).padding(horizontal: 24, vertical: 16),
);
Widget accountProfileLinks(SnAccount data) => Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('links').tr().bold().padding(horizontal: 24, top: 12, bottom: 4),
for (final link in data.profile.links.entries)
ListTile(
title: Text(link.key.capitalizeEachWord()),
subtitle: Text(link.value),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Symbols.chevron_right),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
onTap: () {
launchUrlString(link.value);
},
),
],
),
);
Widget accountAction(SnAccount data) => Card(
child: Column(
children: [
@@ -452,7 +476,7 @@ class AccountProfileScreen extends HookConsumerWidget {
],
),
],
).padding(horizontal: 16, vertical: 8),
).padding(horizontal: 16, vertical: 12),
);
return account.when(
@@ -509,9 +533,11 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter(child: accountBasicInfo(data)),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 24, bottom: 24),
child: Card(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 26, vertical: 20),
).padding(left: 2, right: 4),
),
SliverToBoxAdapter(
child: Column(
@@ -521,9 +547,10 @@ class AccountProfileScreen extends HookConsumerWidget {
level: data.profile.level,
experience: data.profile.experience,
progress: data.profile.levelingProgress,
),
).padding(left: 2, right: 4),
if (data.profile.verification != null)
Card(
margin: EdgeInsets.zero,
child: VerificationStatusCard(
mark: data.profile.verification!,
),
@@ -534,6 +561,9 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter(
child: accountProfileBio(data).padding(top: 4),
),
SliverToBoxAdapter(
child: accountProfileLinks(data),
),
SliverToBoxAdapter(
child: accountProfileDetail(data),
),
@@ -604,9 +634,11 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter(child: accountBasicInfo(data)),
if (data.badges.isNotEmpty)
SliverToBoxAdapter(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 24, bottom: 24),
child: Card(
child: BadgeList(
badges: data.badges,
).padding(horizontal: 26, vertical: 20),
).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: Column(
@@ -628,6 +660,11 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter(
child: accountProfileBio(data).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: accountProfileLinks(
data,
).padding(horizontal: 4),
),
SliverToBoxAdapter(
child: accountProfileDetail(
data,

View File

@@ -0,0 +1,107 @@
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/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/response.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'post_category_detail.g.dart';
@riverpod
Future<SnPostCategory> postCategory(Ref ref, String slug) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/posts/categories/$slug');
return SnPostCategory.fromJson(resp.data);
}
@riverpod
Future<SnPostTag> postTag(Ref ref, String slug) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/posts/tags/$slug');
return SnPostTag.fromJson(resp.data);
}
class PostCategoryDetailScreen extends HookConsumerWidget {
final String slug;
final bool isCategory;
const PostCategoryDetailScreen({
super.key,
required this.slug,
required this.isCategory,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final postCategory =
isCategory ? ref.watch(postCategoryProvider(slug)) : null;
final postTag = isCategory ? null : ref.watch(postTagProvider(slug));
final postFilterTitle =
isCategory
? postCategory?.value?.categoryDisplayTitle ?? 'loading'
: postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text(postFilterTitle).tr()),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isCategory)
postCategory!.when(
data:
(category) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(category.categoryDisplayTitle).bold().fontSize(15),
Text('A category'),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(postCategoryProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
)
else
postTag!.when(
data:
(tag) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tag.name ?? '#${tag.slug}').bold().fontSize(15),
Text('A tag'),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(postTagProvider(slug)),
),
loading: () => ResponseLoadingWidget(),
),
const Divider(height: 1),
Expanded(
child: CustomScrollView(
slivers: [
const SliverGap(4),
SliverPostList(
categories: isCategory ? [slug] : null,
tags: isCategory ? null : [slug],
),
SliverGap(MediaQuery.of(context).padding.bottom + 8),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,270 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'post_category_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$postCategoryHash() => r'0df2de729ba96819ee37377314615abef0c99547';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [postCategory].
@ProviderFor(postCategory)
const postCategoryProvider = PostCategoryFamily();
/// See also [postCategory].
class PostCategoryFamily extends Family<AsyncValue<SnPostCategory>> {
/// See also [postCategory].
const PostCategoryFamily();
/// See also [postCategory].
PostCategoryProvider call(String slug) {
return PostCategoryProvider(slug);
}
@override
PostCategoryProvider getProviderOverride(
covariant PostCategoryProvider provider,
) {
return call(provider.slug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'postCategoryProvider';
}
/// See also [postCategory].
class PostCategoryProvider extends AutoDisposeFutureProvider<SnPostCategory> {
/// See also [postCategory].
PostCategoryProvider(String slug)
: this._internal(
(ref) => postCategory(ref as PostCategoryRef, slug),
from: postCategoryProvider,
name: r'postCategoryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$postCategoryHash,
dependencies: PostCategoryFamily._dependencies,
allTransitiveDependencies:
PostCategoryFamily._allTransitiveDependencies,
slug: slug,
);
PostCategoryProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.slug,
}) : super.internal();
final String slug;
@override
Override overrideWith(
FutureOr<SnPostCategory> Function(PostCategoryRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PostCategoryProvider._internal(
(ref) => create(ref as PostCategoryRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
slug: slug,
),
);
}
@override
AutoDisposeFutureProviderElement<SnPostCategory> createElement() {
return _PostCategoryProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PostCategoryProvider && other.slug == slug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, slug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PostCategoryRef on AutoDisposeFutureProviderRef<SnPostCategory> {
/// The parameter `slug` of this provider.
String get slug;
}
class _PostCategoryProviderElement
extends AutoDisposeFutureProviderElement<SnPostCategory>
with PostCategoryRef {
_PostCategoryProviderElement(super.provider);
@override
String get slug => (origin as PostCategoryProvider).slug;
}
String _$postTagHash() => r'e050fdf9af81a843a9abd9cf979dd2672e0a2b93';
/// See also [postTag].
@ProviderFor(postTag)
const postTagProvider = PostTagFamily();
/// See also [postTag].
class PostTagFamily extends Family<AsyncValue<SnPostTag>> {
/// See also [postTag].
const PostTagFamily();
/// See also [postTag].
PostTagProvider call(String slug) {
return PostTagProvider(slug);
}
@override
PostTagProvider getProviderOverride(covariant PostTagProvider provider) {
return call(provider.slug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'postTagProvider';
}
/// See also [postTag].
class PostTagProvider extends AutoDisposeFutureProvider<SnPostTag> {
/// See also [postTag].
PostTagProvider(String slug)
: this._internal(
(ref) => postTag(ref as PostTagRef, slug),
from: postTagProvider,
name: r'postTagProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$postTagHash,
dependencies: PostTagFamily._dependencies,
allTransitiveDependencies: PostTagFamily._allTransitiveDependencies,
slug: slug,
);
PostTagProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.slug,
}) : super.internal();
final String slug;
@override
Override overrideWith(
FutureOr<SnPostTag> Function(PostTagRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PostTagProvider._internal(
(ref) => create(ref as PostTagRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
slug: slug,
),
);
}
@override
AutoDisposeFutureProviderElement<SnPostTag> createElement() {
return _PostTagProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PostTagProvider && other.slug == slug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, slug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PostTagRef on AutoDisposeFutureProviderRef<SnPostTag> {
/// The parameter `slug` of this provider.
String get slug;
}
class _PostTagProviderElement
extends AutoDisposeFutureProviderElement<SnPostTag>
with PostTagRef {
_PostTagProviderElement(super.provider);
@override
String get slug => (origin as PostTagProvider).slug;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -99,7 +99,10 @@ class PublisherProfileScreen extends HookConsumerWidget {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post("/publishers/$name/subscribe", data: {'tier': 0});
await apiClient.post(
"/sphere/publishers/$name/subscribe",
data: {'tier': 0},
);
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {
@@ -113,7 +116,7 @@ class PublisherProfileScreen extends HookConsumerWidget {
final apiClient = ref.watch(apiClientProvider);
subscribing.value = true;
try {
await apiClient.post("/publishers/$name/unsubscribe");
await apiClient.post("/sphere/publishers/$name/unsubscribe");
ref.invalidate(publisherSubscriptionStatusProvider(name));
HapticFeedback.heavyImpact();
} catch (err) {

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/message.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/widgets/content/network_status_sheet.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
class DebugSheet extends HookConsumerWidget {
const DebugSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final wsNotifier = ref.watch(websocketStateProvider.notifier);
return SheetScaffold(
titleText: 'Debug',
child: Column(
children: [
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.wifi),
trailing: const Icon(Symbols.chevron_right),
title: Text('Connection Status'),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => NetworkStatusSheet(
onReconnect: () => wsNotifier.connect(),
),
);
},
),
const Divider(height: 1),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.copy_all),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Copy access token'),
onTap: () async {
final tk = ref.watch(tokenProvider);
Clipboard.setData(ClipboardData(text: tk!.token));
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.delete),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Reset database'),
onTap: () async {
resetDatabase(ref);
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.clear),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Clear cache'),
onTap: () async {
DefaultCacheManager().emptyCache();
},
),
],
),
);
}
}

View File

@@ -216,6 +216,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
return SheetScaffold(
titleText: 'postSettings'.tr(),
heightFactor: 0.6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(

View File

@@ -572,31 +572,54 @@ class PostItem extends HookConsumerWidget {
),
),
if (item.tags.isNotEmpty)
Wrap(
spacing: 8,
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
for (final tag in item.tags)
InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
const Icon(Symbols.label, size: 16),
Text(tag.name ?? tag.slug),
],
),
onTap: () {},
if (item.tags.isNotEmpty)
Wrap(
runAlignment: WrapAlignment.center,
spacing: 8,
children: [
const Icon(Symbols.label, size: 16).padding(top: 2),
for (final tag
in isFullPost ? item.tags : item.tags.take(3))
InkWell(
child: Text('#${tag.name ?? tag.slug}'),
onTap: () {
GoRouter.of(context).pushNamed(
'postTagDetail',
pathParameters: {'slug': tag.slug},
);
},
),
if (!isFullPost && item.tags.length > 3)
Text('+${item.tags.length - 3}').opacity(0.6),
],
),
for (final category in item.categories)
InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
const Icon(Symbols.category, size: 16),
Text(category.categoryDisplayTitle),
],
),
if (item.categories.isNotEmpty)
Wrap(
runAlignment: WrapAlignment.center,
spacing: 8,
children: [
const Icon(Symbols.category, size: 16).padding(top: 2),
for (final category
in isFullPost
? item.categories
: item.categories.take(2))
InkWell(
child: Text(category.categoryDisplayTitle),
onTap: () {
GoRouter.of(context).pushNamed(
'postCategoryDetail',
pathParameters: {'slug': category.slug},
);
},
),
if (!isFullPost && item.categories.length > 2)
Text('+${item.categories.length - 2}').opacity(0.6),
],
),
],
).padding(horizontal: renderingPadding.horizontal + 4, top: 4),

View File

@@ -15,7 +15,12 @@ class PostListNotifier extends _$PostListNotifier
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnPost>> build(String? pubName, int? type) {
Future<CursorPagingData<SnPost>> build(
String? pubName, {
int? type,
List<String>? categories,
List<String>? tags,
}) {
return fetch(cursor: null);
}
@@ -29,6 +34,8 @@ class PostListNotifier extends _$PostListNotifier
'take': _pageSize,
if (pubName != null) 'pub': pubName,
if (type != null) 'type': type,
if (tags != null) 'tags': tags,
if (categories != null) 'categories': categories,
};
final response = await client.get(
@@ -62,6 +69,8 @@ enum PostItemType {
class SliverPostList extends HookConsumerWidget {
final String? pubName;
final int? type;
final List<String>? categories;
final List<String>? tags;
final PostItemType itemType;
final Color? backgroundColor;
final EdgeInsets? padding;
@@ -73,6 +82,8 @@ class SliverPostList extends HookConsumerWidget {
super.key,
this.pubName,
this.type,
this.categories,
this.tags,
this.itemType = PostItemType.regular,
this.backgroundColor,
this.padding,
@@ -84,9 +95,26 @@ class SliverPostList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return PagingHelperSliverView(
provider: postListNotifierProvider(pubName, type),
futureRefreshable: postListNotifierProvider(pubName, type).future,
notifierRefreshable: postListNotifierProvider(pubName, type).notifier,
provider: postListNotifierProvider(
pubName,
type: type,
categories: categories,
tags: tags,
),
futureRefreshable:
postListNotifierProvider(
pubName,
type: type,
categories: categories,
tags: tags,
).future,
notifierRefreshable:
postListNotifierProvider(
pubName,
type: type,
categories: categories,
tags: tags,
).notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,

View File

@@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'78222d62957f85713d17aecd95af0305b764e86c';
String _$postListNotifierHash() => r'2ca4f3cfbbcd04f3cc32e7f7bd511a5811042829';
/// Copied from Dart SDK
class _SystemHash {
@@ -33,8 +33,15 @@ abstract class _$PostListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPost>> {
late final String? pubName;
late final int? type;
late final List<String>? categories;
late final List<String>? tags;
FutureOr<CursorPagingData<SnPost>> build(String? pubName, int? type);
FutureOr<CursorPagingData<SnPost>> build(
String? pubName, {
int? type,
List<String>? categories,
List<String>? tags,
});
}
/// See also [PostListNotifier].
@@ -48,15 +55,30 @@ class PostListNotifierFamily
const PostListNotifierFamily();
/// See also [PostListNotifier].
PostListNotifierProvider call(String? pubName, int? type) {
return PostListNotifierProvider(pubName, type);
PostListNotifierProvider call(
String? pubName, {
int? type,
List<String>? categories,
List<String>? tags,
}) {
return PostListNotifierProvider(
pubName,
type: type,
categories: categories,
tags: tags,
);
}
@override
PostListNotifierProvider getProviderOverride(
covariant PostListNotifierProvider provider,
) {
return call(provider.pubName, provider.type);
return call(
provider.pubName,
type: provider.type,
categories: provider.categories,
tags: provider.tags,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@@ -82,24 +104,32 @@ class PostListNotifierProvider
CursorPagingData<SnPost>
> {
/// See also [PostListNotifier].
PostListNotifierProvider(String? pubName, int? type)
: this._internal(
() =>
PostListNotifier()
..pubName = pubName
..type = type,
from: postListNotifierProvider,
name: r'postListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$postListNotifierHash,
dependencies: PostListNotifierFamily._dependencies,
allTransitiveDependencies:
PostListNotifierFamily._allTransitiveDependencies,
pubName: pubName,
type: type,
);
PostListNotifierProvider(
String? pubName, {
int? type,
List<String>? categories,
List<String>? tags,
}) : this._internal(
() =>
PostListNotifier()
..pubName = pubName
..type = type
..categories = categories
..tags = tags,
from: postListNotifierProvider,
name: r'postListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$postListNotifierHash,
dependencies: PostListNotifierFamily._dependencies,
allTransitiveDependencies:
PostListNotifierFamily._allTransitiveDependencies,
pubName: pubName,
type: type,
categories: categories,
tags: tags,
);
PostListNotifierProvider._internal(
super._createNotifier, {
@@ -110,16 +140,25 @@ class PostListNotifierProvider
required super.from,
required this.pubName,
required this.type,
required this.categories,
required this.tags,
}) : super.internal();
final String? pubName;
final int? type;
final List<String>? categories;
final List<String>? tags;
@override
FutureOr<CursorPagingData<SnPost>> runNotifierBuild(
covariant PostListNotifier notifier,
) {
return notifier.build(pubName, type);
return notifier.build(
pubName,
type: type,
categories: categories,
tags: tags,
);
}
@override
@@ -130,7 +169,9 @@ class PostListNotifierProvider
() =>
create()
..pubName = pubName
..type = type,
..type = type
..categories = categories
..tags = tags,
from: from,
name: null,
dependencies: null,
@@ -138,6 +179,8 @@ class PostListNotifierProvider
debugGetCreateSourceHash: null,
pubName: pubName,
type: type,
categories: categories,
tags: tags,
),
);
}
@@ -155,7 +198,9 @@ class PostListNotifierProvider
bool operator ==(Object other) {
return other is PostListNotifierProvider &&
other.pubName == pubName &&
other.type == type;
other.type == type &&
other.categories == categories &&
other.tags == tags;
}
@override
@@ -163,6 +208,8 @@ class PostListNotifierProvider
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
hash = _SystemHash.combine(hash, type.hashCode);
hash = _SystemHash.combine(hash, categories.hashCode);
hash = _SystemHash.combine(hash, tags.hashCode);
return _SystemHash.finish(hash);
}
@@ -177,6 +224,12 @@ mixin PostListNotifierRef
/// The parameter `type` of this provider.
int? get type;
/// The parameter `categories` of this provider.
List<String>? get categories;
/// The parameter `tags` of this provider.
List<String>? get tags;
}
class _PostListNotifierProviderElement
@@ -192,6 +245,11 @@ class _PostListNotifierProviderElement
String? get pubName => (origin as PostListNotifierProvider).pubName;
@override
int? get type => (origin as PostListNotifierProvider).type;
@override
List<String>? get categories =>
(origin as PostListNotifierProvider).categories;
@override
List<String>? get tags => (origin as PostListNotifierProvider).tags;
}
// ignore_for_file: type=lint

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.1.0+117
version: 3.1.0+118
environment:
sdk: ^3.7.2