From 5ee2e704423da3e302e855c6cfd427c4d17db4d7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 1 Nov 2025 20:16:54 +0800 Subject: [PATCH] :sparkles: New activity presence --- lib/models/activity.dart | 22 ++ lib/models/activity.freezed.dart | 301 +++++++++++++++++++++ lib/models/activity.g.dart | 37 +++ lib/pods/activity/activity_rpc.dart | 82 ++++-- lib/screens/account/profile.dart | 9 + lib/widgets/account/activity_presence.dart | 60 ++++ 6 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 lib/widgets/account/activity_presence.dart diff --git a/lib/models/activity.dart b/lib/models/activity.dart index 6255b7d2..c2c409a0 100644 --- a/lib/models/activity.dart +++ b/lib/models/activity.dart @@ -74,3 +74,25 @@ sealed class SnEventCalendarEntry with _$SnEventCalendarEntry { factory SnEventCalendarEntry.fromJson(Map json) => _$SnEventCalendarEntryFromJson(json); } + +@freezed +sealed class SnPresenceActivity with _$SnPresenceActivity { + const factory SnPresenceActivity({ + required String id, + required String type, + required String? manualId, + required String? title, + required String? subtitle, + required String? caption, + required Map? meta, + required int leaseMinutes, + required DateTime leaseExpiresAt, + required String accountId, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnPresenceActivity; + + factory SnPresenceActivity.fromJson(Map json) => + _$SnPresenceActivityFromJson(json); +} diff --git a/lib/models/activity.freezed.dart b/lib/models/activity.freezed.dart index 6fa72f25..5fdd173e 100644 --- a/lib/models/activity.freezed.dart +++ b/lib/models/activity.freezed.dart @@ -1425,4 +1425,305 @@ $SnCheckInResultCopyWith<$Res>? get checkInResult { } } + +/// @nodoc +mixin _$SnPresenceActivity { + + String get id; String get type; String? get manualId; String? get title; String? get subtitle; String? get caption; Map? get meta; int get leaseMinutes; DateTime get leaseExpiresAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnPresenceActivity +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnPresenceActivityCopyWith get copyWith => _$SnPresenceActivityCopyWithImpl(this as SnPresenceActivity, _$identity); + + /// Serializes this SnPresenceActivity to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(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,manualId,title,subtitle,caption,const DeepCollectionEquality().hash(meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnPresenceActivityCopyWith<$Res> { + factory $SnPresenceActivityCopyWith(SnPresenceActivity value, $Res Function(SnPresenceActivity) _then) = _$SnPresenceActivityCopyWithImpl; +@useResult +$Res call({ + String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnPresenceActivityCopyWithImpl<$Res> + implements $SnPresenceActivityCopyWith<$Res> { + _$SnPresenceActivityCopyWithImpl(this._self, this._then); + + final SnPresenceActivity _self; + final $Res Function(SnPresenceActivity) _then; + +/// Create a copy of SnPresenceActivity +/// 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? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,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,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable +as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable +as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable +as String?,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable +as Map?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable +as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable +as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,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?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnPresenceActivity]. +extension SnPresenceActivityPatterns on SnPresenceActivity { +/// 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( _SnPresenceActivity value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnPresenceActivity() 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( _SnPresenceActivity value) $default,){ +final _that = this; +switch (_that) { +case _SnPresenceActivity(): +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( _SnPresenceActivity value)? $default,){ +final _that = this; +switch (_that) { +case _SnPresenceActivity() 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( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnPresenceActivity() when $default != null: +return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);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( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; +switch (_that) { +case _SnPresenceActivity(): +return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} +} +/// 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( String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; +switch (_that) { +case _SnPresenceActivity() when $default != null: +return $default(_that.id,_that.type,_that.manualId,_that.title,_that.subtitle,_that.caption,_that.meta,_that.leaseMinutes,_that.leaseExpiresAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnPresenceActivity implements SnPresenceActivity { + const _SnPresenceActivity({required this.id, required this.type, required this.manualId, required this.title, required this.subtitle, required this.caption, required final Map? meta, required this.leaseMinutes, required this.leaseExpiresAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta; + factory _SnPresenceActivity.fromJson(Map json) => _$SnPresenceActivityFromJson(json); + +@override final String id; +@override final String type; +@override final String? manualId; +@override final String? title; +@override final String? subtitle; +@override final String? caption; + final Map? _meta; +@override Map? get meta { + final value = _meta; + if (value == null) return null; + if (_meta is EqualUnmodifiableMapView) return _meta; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); +} + +@override final int leaseMinutes; +@override final DateTime leaseExpiresAt; +@override final String accountId; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnPresenceActivity +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnPresenceActivityCopyWith<_SnPresenceActivity> get copyWith => __$SnPresenceActivityCopyWithImpl<_SnPresenceActivity>(this, _$identity); + +@override +Map toJson() { + return _$SnPresenceActivityToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPresenceActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.manualId, manualId) || other.manualId == manualId)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.caption, caption) || other.caption == caption)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.leaseMinutes, leaseMinutes) || other.leaseMinutes == leaseMinutes)&&(identical(other.leaseExpiresAt, leaseExpiresAt) || other.leaseExpiresAt == leaseExpiresAt)&&(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,manualId,title,subtitle,caption,const DeepCollectionEquality().hash(_meta),leaseMinutes,leaseExpiresAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnPresenceActivity(id: $id, type: $type, manualId: $manualId, title: $title, subtitle: $subtitle, caption: $caption, meta: $meta, leaseMinutes: $leaseMinutes, leaseExpiresAt: $leaseExpiresAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnPresenceActivityCopyWith<$Res> implements $SnPresenceActivityCopyWith<$Res> { + factory _$SnPresenceActivityCopyWith(_SnPresenceActivity value, $Res Function(_SnPresenceActivity) _then) = __$SnPresenceActivityCopyWithImpl; +@override @useResult +$Res call({ + String id, String type, String? manualId, String? title, String? subtitle, String? caption, Map? meta, int leaseMinutes, DateTime leaseExpiresAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnPresenceActivityCopyWithImpl<$Res> + implements _$SnPresenceActivityCopyWith<$Res> { + __$SnPresenceActivityCopyWithImpl(this._self, this._then); + + final _SnPresenceActivity _self; + final $Res Function(_SnPresenceActivity) _then; + +/// Create a copy of SnPresenceActivity +/// 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? manualId = freezed,Object? title = freezed,Object? subtitle = freezed,Object? caption = freezed,Object? meta = freezed,Object? leaseMinutes = null,Object? leaseExpiresAt = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnPresenceActivity( +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,manualId: freezed == manualId ? _self.manualId : manualId // ignore: cast_nullable_to_non_nullable +as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String?,subtitle: freezed == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable +as String?,caption: freezed == caption ? _self.caption : caption // ignore: cast_nullable_to_non_nullable +as String?,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable +as Map?,leaseMinutes: null == leaseMinutes ? _self.leaseMinutes : leaseMinutes // ignore: cast_nullable_to_non_nullable +as int,leaseExpiresAt: null == leaseExpiresAt ? _self.leaseExpiresAt : leaseExpiresAt // ignore: cast_nullable_to_non_nullable +as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as String,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/activity.g.dart b/lib/models/activity.g.dart index ff416167..59ed1194 100644 --- a/lib/models/activity.g.dart +++ b/lib/models/activity.g.dart @@ -121,3 +121,40 @@ Map _$SnEventCalendarEntryToJson( 'check_in_result': instance.checkInResult?.toJson(), 'statuses': instance.statuses.map((e) => e.toJson()).toList(), }; + +_SnPresenceActivity _$SnPresenceActivityFromJson(Map json) => + _SnPresenceActivity( + id: json['id'] as String, + type: json['type'] as String, + manualId: json['manual_id'] as String?, + title: json['title'] as String?, + subtitle: json['subtitle'] as String?, + caption: json['caption'] as String?, + meta: json['meta'] as Map?, + leaseMinutes: (json['lease_minutes'] as num).toInt(), + leaseExpiresAt: DateTime.parse(json['lease_expires_at'] as String), + accountId: json['account_id'] as String, + 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 _$SnPresenceActivityToJson(_SnPresenceActivity instance) => + { + 'id': instance.id, + 'type': instance.type, + 'manual_id': instance.manualId, + 'title': instance.title, + 'subtitle': instance.subtitle, + 'caption': instance.caption, + 'meta': instance.meta, + 'lease_minutes': instance.leaseMinutes, + 'lease_expires_at': instance.leaseExpiresAt.toIso8601String(), + 'account_id': instance.accountId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/activity/activity_rpc.dart b/lib/pods/activity/activity_rpc.dart index 003ef285..7e587ef2 100644 --- a/lib/pods/activity/activity_rpc.dart +++ b/lib/pods/activity/activity_rpc.dart @@ -4,14 +4,18 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/account.dart'; +import 'package:island/models/activity.dart'; import 'package:island/pods/network.dart'; import 'package:island/talker.dart'; import 'package:island/widgets/account/status.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +part 'activity_rpc.g.dart'; + // Conditional imports for IPC server - use web stubs on web platform import 'ipc_server.dart' if (dart.library.html) 'ipc_server.web.dart'; @@ -294,13 +298,24 @@ class _WsSocketWrapper { class ServerState { final String status; final List activities; + final String? currentActivityManualId; - ServerState({required this.status, this.activities = const []}); + ServerState({ + required this.status, + this.activities = const [], + this.currentActivityManualId, + }); - ServerState copyWith({String? status, List? activities}) { + ServerState copyWith({ + String? status, + List? activities, + String? currentActivityManualId, + }) { return ServerState( status: status ?? this.status, activities: activities ?? this.activities, + currentActivityManualId: + currentActivityManualId ?? this.currentActivityManualId, ); } } @@ -333,6 +348,10 @@ class ServerStateNotifier extends StateNotifier { void addActivity(String activity) { state = state.copyWith(activities: [...state.activities, activity]); } + + void setCurrentActivityManualId(String? id) { + state = state.copyWith(currentActivityManualId: id); + } } // Providers @@ -377,7 +396,26 @@ final rpcServerStateProvider = final appId = socket.clientId; final meta = data['args']['activity']; try { - await setRemoteActivityStatus(ref, label, appId, meta); + final apiClient = ref.watch(apiClientProvider); + final currentId = notifier.state.currentActivityManualId; + final isUpdate = currentId == appId; + final activityData = { + 'type': 'Gaming', + 'manualId': appId, + 'title': label, + 'meta': meta, + 'leaseMinutes': 30, + }; + if (isUpdate) { + await apiClient.put( + '/pass/activities', + queryParameters: {'manual_id': appId}, + data: {'leaseMinutes': 30}, + ); + } else { + await apiClient.post('/pass/activities', data: activityData); + notifier.setCurrentActivityManualId(appId); + } final now = DateTime.now(); final status = SnAccountStatus( id: 'local_$appId', @@ -410,7 +448,12 @@ final rpcServerStateProvider = notifier.updateStatus('Client disconnected'); final appId = socket.clientId; try { - await unsetRemoteActivityStatus(ref, appId); + final apiClient = ref.watch(apiClientProvider); + await apiClient.delete( + '/pass/activities', + queryParameters: {'manual_id': appId}, + ); + notifier.setCurrentActivityManualId(null); ref.read(currentAccountStatusProvider.notifier).clearStatus(); } catch (e) { talker.log('Failed to unset remote activity status: $e'); @@ -425,30 +468,13 @@ final rpcServerProvider = Provider((ref) { return notifier.server; }); -Future setRemoteActivityStatus( +@riverpod +Future> presenceActivities( Ref ref, - String label, - String appId, - Map meta, + String uname, ) async { - final apiClient = ref.read(apiClientProvider); - await apiClient.post( - '/pass/accounts/me/statuses', - data: { - 'is_invisible': false, - 'is_not_disturb': false, - 'is_automated': true, - 'label': label, - 'app_identifier': appId, - 'meta': meta, - }, - ); -} - -Future unsetRemoteActivityStatus(Ref ref, String appId) async { - final apiClient = ref.read(apiClientProvider); - await apiClient.delete( - '/pass/accounts/me/statuses', - queryParameters: {'app': appId}, - ); + final apiClient = ref.watch(apiClientProvider); + final response = await apiClient.get('/pass/accounts/$uname/activities'); + final data = response.data as List; + return data.map((json) => SnPresenceActivity.fromJson(json)).toList(); } diff --git a/lib/screens/account/profile.dart b/lib/screens/account/profile.dart index 844e57ac..d2bdfe54 100644 --- a/lib/screens/account/profile.dart +++ b/lib/screens/account/profile.dart @@ -22,6 +22,7 @@ import 'package:island/utils/text.dart'; import 'package:island/services/time.dart'; import 'package:island/services/timezone/native.dart'; import 'package:island/widgets/account/account_name.dart'; +import 'package:island/widgets/account/activity_presence.dart'; import 'package:island/widgets/account/badge.dart'; import 'package:island/widgets/account/fortune_graph.dart'; import 'package:island/widgets/account/leveling_progress.dart'; @@ -882,6 +883,9 @@ class AccountProfileScreen extends HookConsumerWidget { ), ), ), + SliverToBoxAdapter( + child: ActivityPresenceWidget(uname: name), + ), ], ), ), @@ -1006,6 +1010,11 @@ class AccountProfileScreen extends HookConsumerWidget { ), ).padding(horizontal: 4), ), + SliverToBoxAdapter( + child: ActivityPresenceWidget( + uname: name, + ).padding(horizontal: 4), + ), ], ), ); diff --git a/lib/widgets/account/activity_presence.dart b/lib/widgets/account/activity_presence.dart new file mode 100644 index 00000000..f3c65357 --- /dev/null +++ b/lib/widgets/account/activity_presence.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/activity.dart'; +import 'package:island/pods/activity/activity_rpc.dart'; + +class ActivityPresenceWidget extends ConsumerWidget { + final String uname; + + const ActivityPresenceWidget({super.key, required this.uname}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activitiesAsync = ref.watch(presenceActivitiesProvider(uname)); + + return activitiesAsync.when( + data: (activities) => _buildActivitiesList(activities), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => + Center(child: Text('Error loading activities: $error')), + ); + } + + Widget _buildActivitiesList(List activities) { + if (activities.isEmpty) { + return const Center(child: Text('No active activities')); + } + + return ListView.builder( + itemCount: activities.length, + itemBuilder: (context, index) { + final activity = activities[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + title: Text(activity.title ?? 'Untitled Activity'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Type: ${activity.type}'), + if (activity.subtitle != null) Text(activity.subtitle!), + if (activity.caption != null) Text(activity.caption!), + Text( + 'Expires: ${activity.leaseExpiresAt.toLocal().toString()}', + style: const TextStyle(fontSize: 12), + ), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + // TODO: Implement delete functionality + }, + ), + ), + ); + }, + ); + } +}