From 82397dd08748fb6ce25ace28889d7d52c604b11e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 16 Nov 2025 17:26:31 +0800 Subject: [PATCH] :sparkles: Friends overview basis --- lib/models/account.dart | 13 + lib/models/account.freezed.dart | 305 ++++++++++++++++++++ lib/models/account.g.dart | 19 ++ lib/screens/explore.dart | 2 + lib/widgets/account/friends_overview.dart | 150 ++++++++++ lib/widgets/account/friends_overview.g.dart | 30 ++ 6 files changed, 519 insertions(+) create mode 100644 lib/widgets/account/friends_overview.dart create mode 100644 lib/widgets/account/friends_overview.g.dart diff --git a/lib/models/account.dart b/lib/models/account.dart index 5f6a9daf..96ee5622 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/models/activity.dart'; import 'package:island/models/auth.dart'; import 'package:island/models/file.dart'; import 'package:island/models/wallet.dart'; @@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord { factory SnSocialCreditRecord.fromJson(Map json) => _$SnSocialCreditRecordFromJson(json); } + +@freezed +sealed class SnFriendOverviewItem with _$SnFriendOverviewItem { + const factory SnFriendOverviewItem({ + required SnAccount account, + required SnAccountStatus status, + required List activities, + }) = _SnFriendOverviewItem; + + factory SnFriendOverviewItem.fromJson(Map json) => + _$SnFriendOverviewItemFromJson(json); +} diff --git a/lib/models/account.freezed.dart b/lib/models/account.freezed.dart index edc42c3a..152293c3 100644 --- a/lib/models/account.freezed.dart +++ b/lib/models/account.freezed.dart @@ -3912,4 +3912,309 @@ as DateTime?, } + +/// @nodoc +mixin _$SnFriendOverviewItem { + + SnAccount get account; SnAccountStatus get status; List get activities; +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnFriendOverviewItemCopyWith get copyWith => _$SnFriendOverviewItemCopyWithImpl(this as SnFriendOverviewItem, _$identity); + + /// Serializes this SnFriendOverviewItem to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other.activities, activities)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(activities)); + +@override +String toString() { + return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)'; +} + + +} + +/// @nodoc +abstract mixin class $SnFriendOverviewItemCopyWith<$Res> { + factory $SnFriendOverviewItemCopyWith(SnFriendOverviewItem value, $Res Function(SnFriendOverviewItem) _then) = _$SnFriendOverviewItemCopyWithImpl; +@useResult +$Res call({ + SnAccount account, SnAccountStatus status, List activities +}); + + +$SnAccountCopyWith<$Res> get account;$SnAccountStatusCopyWith<$Res> get status; + +} +/// @nodoc +class _$SnFriendOverviewItemCopyWithImpl<$Res> + implements $SnFriendOverviewItemCopyWith<$Res> { + _$SnFriendOverviewItemCopyWithImpl(this._self, this._then); + + final SnFriendOverviewItem _self; + final $Res Function(SnFriendOverviewItem) _then; + +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? account = null,Object? status = null,Object? activities = null,}) { + return _then(_self.copyWith( +account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable +as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as SnAccountStatus,activities: null == activities ? _self.activities : activities // ignore: cast_nullable_to_non_nullable +as List, + )); +} +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountCopyWith<$Res> get account { + + return $SnAccountCopyWith<$Res>(_self.account, (value) { + return _then(_self.copyWith(account: value)); + }); +}/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountStatusCopyWith<$Res> get status { + + return $SnAccountStatusCopyWith<$Res>(_self.status, (value) { + return _then(_self.copyWith(status: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [SnFriendOverviewItem]. +extension SnFriendOverviewItemPatterns on SnFriendOverviewItem { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SnFriendOverviewItem value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnFriendOverviewItem() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SnFriendOverviewItem value) $default,){ +final _that = this; +switch (_that) { +case _SnFriendOverviewItem(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SnFriendOverviewItem value)? $default,){ +final _that = this; +switch (_that) { +case _SnFriendOverviewItem() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( SnAccount account, SnAccountStatus status, List activities)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnFriendOverviewItem() when $default != null: +return $default(_that.account,_that.status,_that.activities);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( SnAccount account, SnAccountStatus status, List activities) $default,) {final _that = this; +switch (_that) { +case _SnFriendOverviewItem(): +return $default(_that.account,_that.status,_that.activities);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( SnAccount account, SnAccountStatus status, List activities)? $default,) {final _that = this; +switch (_that) { +case _SnFriendOverviewItem() when $default != null: +return $default(_that.account,_that.status,_that.activities);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnFriendOverviewItem implements SnFriendOverviewItem { + const _SnFriendOverviewItem({required this.account, required this.status, required final List activities}): _activities = activities; + factory _SnFriendOverviewItem.fromJson(Map json) => _$SnFriendOverviewItemFromJson(json); + +@override final SnAccount account; +@override final SnAccountStatus status; + final List _activities; +@override List get activities { + if (_activities is EqualUnmodifiableListView) return _activities; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_activities); +} + + +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnFriendOverviewItemCopyWith<_SnFriendOverviewItem> get copyWith => __$SnFriendOverviewItemCopyWithImpl<_SnFriendOverviewItem>(this, _$identity); + +@override +Map toJson() { + return _$SnFriendOverviewItemToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other._activities, _activities)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(_activities)); + +@override +String toString() { + return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnFriendOverviewItemCopyWith<$Res> implements $SnFriendOverviewItemCopyWith<$Res> { + factory _$SnFriendOverviewItemCopyWith(_SnFriendOverviewItem value, $Res Function(_SnFriendOverviewItem) _then) = __$SnFriendOverviewItemCopyWithImpl; +@override @useResult +$Res call({ + SnAccount account, SnAccountStatus status, List activities +}); + + +@override $SnAccountCopyWith<$Res> get account;@override $SnAccountStatusCopyWith<$Res> get status; + +} +/// @nodoc +class __$SnFriendOverviewItemCopyWithImpl<$Res> + implements _$SnFriendOverviewItemCopyWith<$Res> { + __$SnFriendOverviewItemCopyWithImpl(this._self, this._then); + + final _SnFriendOverviewItem _self; + final $Res Function(_SnFriendOverviewItem) _then; + +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? account = null,Object? status = null,Object? activities = null,}) { + return _then(_SnFriendOverviewItem( +account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable +as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as SnAccountStatus,activities: null == activities ? _self._activities : activities // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountCopyWith<$Res> get account { + + return $SnAccountCopyWith<$Res>(_self.account, (value) { + return _then(_self.copyWith(account: value)); + }); +}/// Create a copy of SnFriendOverviewItem +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnAccountStatusCopyWith<$Res> get status { + + return $SnAccountStatusCopyWith<$Res>(_self.status, (value) { + return _then(_self.copyWith(status: value)); + }); +} +} + // dart format on diff --git a/lib/models/account.g.dart b/lib/models/account.g.dart index 3f0a434e..f48572a5 100644 --- a/lib/models/account.g.dart +++ b/lib/models/account.g.dart @@ -449,3 +449,22 @@ Map _$SnSocialCreditRecordToJson( 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), }; + +_SnFriendOverviewItem _$SnFriendOverviewItemFromJson( + Map json, +) => _SnFriendOverviewItem( + account: SnAccount.fromJson(json['account'] as Map), + status: SnAccountStatus.fromJson(json['status'] as Map), + activities: + (json['activities'] as List) + .map((e) => SnPresenceActivity.fromJson(e as Map)) + .toList(), +); + +Map _$SnFriendOverviewItemToJson( + _SnFriendOverviewItem instance, +) => { + 'account': instance.account.toJson(), + 'status': instance.status.toJson(), + 'activities': instance.activities.map((e) => e.toJson()).toList(), +}; diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 818355bc..b457a193 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -14,6 +14,7 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/screens/auth/login_modal.dart'; import 'package:island/screens/notification.dart'; import 'package:island/services/responsive.dart'; +import 'package:island/widgets/account/friends_overview.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/models/post.dart'; import 'package:island/widgets/check_in.dart'; @@ -341,6 +342,7 @@ class ExploreScreen extends HookConsumerWidget { margin: EdgeInsets.zero, ), PostFeaturedList(), + FriendsOverviewWidget(), ], ), ), diff --git a/lib/widgets/account/friends_overview.dart b/lib/widgets/account/friends_overview.dart new file mode 100644 index 00000000..444240e2 --- /dev/null +++ b/lib/widgets/account/friends_overview.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:island/models/account.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/pods/config.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:gap/gap.dart'; + +part 'friends_overview.g.dart'; + +@riverpod +Future> friendsOverview(Ref ref) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/pass/friends/overview'); + return (resp.data as List) + .map((e) => SnFriendOverviewItem.fromJson(e)) + .toList(); +} + +class FriendsOverviewWidget extends ConsumerWidget { + const FriendsOverviewWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final friendsOverviewAsync = ref.watch(friendsOverviewProvider); + + return friendsOverviewAsync.when( + data: (friends) { + // Filter for online friends + final onlineFriends = + friends.where((friend) => friend.status.isOnline).toList(); + + if (onlineFriends.isEmpty) { + return const SizedBox.shrink(); // Hide if no online friends + } + + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + Row( + spacing: 8, + children: [const Icon(Symbols.group), Text('Friends Online')], + ).padding(horizontal: 16).height(48), + SizedBox( + height: 80, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + scrollDirection: Axis.horizontal, + itemCount: onlineFriends.length, + itemBuilder: (context, index) { + final friend = onlineFriends[index]; + return _FriendTile(friend: friend); + }, + ), + ), + ], + ), + ); + }, + loading: + () => const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => const SizedBox.shrink(), // Hide on error + ); + } +} + +class _FriendTile extends ConsumerWidget { + final SnFriendOverviewItem friend; + + const _FriendTile({required this.friend}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final serverUrl = ref.watch(serverUrlProvider); + + String? uri; + if (friend.account.profile.picture != null) { + uri = '$serverUrl/drive/files/${friend.account.profile.picture!.id}'; + } + + return Container( + width: 60, + margin: const EdgeInsets.only(right: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Avatar with online indicator + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: + uri != null ? CachedNetworkImageProvider(uri) : null, + child: + uri == null + ? Text( + friend.account.nick.isNotEmpty + ? friend.account.nick[0].toUpperCase() + : friend.account.name[0].toUpperCase(), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ) + : null, + ), + // Online indicator + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.surface, + width: 2, + ), + ), + ), + ), + ], + ), + const Gap(4), + // Name (truncated if too long) + Text( + friend.account.nick.isNotEmpty + ? friend.account.nick + : friend.account.name, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ).center(); + } +} diff --git a/lib/widgets/account/friends_overview.g.dart b/lib/widgets/account/friends_overview.g.dart new file mode 100644 index 00000000..0e43adeb --- /dev/null +++ b/lib/widgets/account/friends_overview.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'friends_overview.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$friendsOverviewHash() => r'5ef86c6849804c97abd3df094f120c7dd5e938db'; + +/// See also [friendsOverview]. +@ProviderFor(friendsOverview) +final friendsOverviewProvider = + AutoDisposeFutureProvider>.internal( + friendsOverview, + name: r'friendsOverviewProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$friendsOverviewHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FriendsOverviewRef = + AutoDisposeFutureProviderRef>; +// 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