Compare commits

..

5 Commits

Author SHA1 Message Date
LittleSheep
0bbfa6ddde 🚀 Launch 3.0.0+104 2025-06-09 01:10:43 +08:00
LittleSheep
1f46f89056 👽 Support new activity API 2025-06-09 01:06:52 +08:00
LittleSheep
7740cf7830 🐛 Fixes members' loading bugs 2025-06-08 22:33:47 +08:00
LittleSheep
d165e488ad 💄 Optimized image loading and error 2025-06-08 20:31:32 +08:00
LittleSheep
37f5c61905 🐛 Fixes on video caching policy 2025-06-08 20:22:46 +08:00
17 changed files with 681 additions and 348 deletions

View File

@@ -10,13 +10,10 @@ sealed class SnActivity with _$SnActivity {
required String id,
required String type,
required String resourceIdentifier,
required int visibility,
required String accountId,
required SnAccount account,
required dynamic data,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required DateTime? deletedAt,
}) = _SnActivity;
factory SnActivity.fromJson(Map<String, dynamic> json) =>

View File

@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnActivity {
String get id; String get type; String get resourceIdentifier; int get visibility; String get accountId; SnAccount get account; dynamic get data; DateTime get createdAt; DateTime get updatedAt; dynamic get deletedAt;
String get id; String get type; String get resourceIdentifier; dynamic get data; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -29,16 +29,16 @@ $SnActivityCopyWith<SnActivity> get copyWith => _$SnActivityCopyWithImpl<SnActiv
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other.deletedAt, deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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,resourceIdentifier,visibility,accountId,account,const DeepCollectionEquality().hash(data),createdAt,updatedAt,const DeepCollectionEquality().hash(deletedAt));
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const DeepCollectionEquality().hash(data),createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, visibility: $visibility, accountId: $accountId, account: $account, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -49,11 +49,11 @@ abstract mixin class $SnActivityCopyWith<$Res> {
factory $SnActivityCopyWith(SnActivity value, $Res Function(SnActivity) _then) = _$SnActivityCopyWithImpl;
@useResult
$Res call({
String id, String type, String resourceIdentifier, int visibility, String accountId, SnAccount account, dynamic data, DateTime createdAt, DateTime updatedAt, dynamic deletedAt
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
@@ -66,31 +66,19 @@ class _$SnActivityCopyWithImpl<$Res>
/// Create a copy of SnActivity
/// 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? resourceIdentifier = null,Object? visibility = null,Object? accountId = null,Object? account = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,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 dynamic,
as DateTime?,
));
}
/// Create a copy of SnActivity
/// 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));
});
}
}
@@ -98,19 +86,16 @@ $SnAccountCopyWith<$Res> get account {
@JsonSerializable()
class _SnActivity implements SnActivity {
const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.visibility, required this.accountId, required this.account, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnActivity.fromJson(Map<String, dynamic> json) => _$SnActivityFromJson(json);
@override final String id;
@override final String type;
@override final String resourceIdentifier;
@override final int visibility;
@override final String accountId;
@override final SnAccount account;
@override final dynamic data;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final dynamic deletedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@@ -125,16 +110,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other.deletedAt, deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(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,resourceIdentifier,visibility,accountId,account,const DeepCollectionEquality().hash(data),createdAt,updatedAt,const DeepCollectionEquality().hash(deletedAt));
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const DeepCollectionEquality().hash(data),createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, visibility: $visibility, accountId: $accountId, account: $account, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -145,11 +130,11 @@ abstract mixin class _$SnActivityCopyWith<$Res> implements $SnActivityCopyWith<$
factory _$SnActivityCopyWith(_SnActivity value, $Res Function(_SnActivity) _then) = __$SnActivityCopyWithImpl;
@override @useResult
$Res call({
String id, String type, String resourceIdentifier, int visibility, String accountId, SnAccount account, dynamic data, DateTime createdAt, DateTime updatedAt, dynamic deletedAt
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@override $SnAccountCopyWith<$Res> get account;
}
/// @nodoc
@@ -162,32 +147,20 @@ class __$SnActivityCopyWithImpl<$Res>
/// Create a copy of SnActivity
/// 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? resourceIdentifier = null,Object? visibility = null,Object? accountId = null,Object? account = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnActivity(
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,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,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 dynamic,
as DateTime?,
));
}
/// Create a copy of SnActivity
/// 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));
});
}
}

View File

@@ -10,13 +10,13 @@ _SnActivity _$SnActivityFromJson(Map<String, dynamic> json) => _SnActivity(
id: json['id'] as String,
type: json['type'] as String,
resourceIdentifier: json['resource_identifier'] as String,
visibility: (json['visibility'] as num).toInt(),
accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
deletedAt:
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
@@ -24,13 +24,10 @@ Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
'id': instance.id,
'type': instance.type,
'resource_identifier': instance.resourceIdentifier,
'visibility': instance.visibility,
'account_id': instance.accountId,
'account': instance.account.toJson(),
'data': instance.data,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnCheckInResult _$SnCheckInResultFromJson(Map<String, dynamic> json) =>

View File

@@ -14,10 +14,14 @@ import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/paging_helper_ext.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'room_detail.freezed.dart';
part 'room_detail.g.dart';
@RoutePage()
class ChatDetailScreen extends HookConsumerWidget {
@@ -287,12 +291,51 @@ class ChatMemberNotifier extends StateNotifier<ChatRoomMemberState> {
}
}
@riverpod
class ChatMemberListNotifier extends _$ChatMemberListNotifier
with CursorPagingNotifierMixin<SnChatMember> {
@override
Future<CursorPagingData<SnChatMember>> build(String roomId) {
return fetch();
}
@override
Future<CursorPagingData<SnChatMember>> fetch({String? cursor}) async {
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get(
'/chat/$roomId/members',
queryParameters: {'offset': offset, 'take': take},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnChatMember.fromJson(e)).toList();
// Calculate next cursor based on total count
final nextOffset = offset + members.length;
final String? nextCursor =
nextOffset < total ? nextOffset.toString() : null;
return CursorPagingData(
items: members,
nextCursor: nextCursor,
hasMore: members.length < total,
);
}
}
class _ChatMemberListSheet extends HookConsumerWidget {
final String roomId;
const _ChatMemberListSheet({required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memberListProvider = chatMemberListNotifierProvider(roomId);
// For backward compatibility and to show total count in the header
final memberState = ref.watch(chatMemberStateProvider(roomId));
final memberNotifier = ref.read(chatMemberStateProvider(roomId).notifier);
@@ -318,8 +361,10 @@ class _ChatMemberListSheet extends HookConsumerWidget {
'/chat/invites/$roomId',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
memberNotifier.reset();
await memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
@@ -351,8 +396,10 @@ class _ChatMemberListSheet extends HookConsumerWidget {
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
},
),
IconButton(
@@ -365,108 +412,103 @@ class _ChatMemberListSheet extends HookConsumerWidget {
),
const Divider(height: 1),
Expanded(
child:
memberState.error != null
? Center(child: Text(memberState.error!))
: ListView.builder(
itemCount: memberState.members.length + 1,
itemBuilder: (context, index) {
if (index == memberState.members.length) {
if (memberState.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
if (memberState.members.length < memberState.total) {
memberNotifier.loadMore(
offset: memberState.members.length,
);
}
return const SizedBox.shrink();
}
child: PagingHelperView(
provider: memberListProvider,
futureRefreshable: memberListProvider.future,
notifierRefreshable: memberListProvider.notifier,
contentBuilder: (data, widgetCount, endItemView) {
return ListView.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == data.items.length) {
return endItemView;
}
final member = memberState.members[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _ChatMemberRoleSheet(
roomId: roomId,
member: member,
),
).then((value) {
if (value != null) {
memberNotifier.reset();
memberNotifier.loadMore();
}
});
},
),
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeChatMemberHint'.tr(),
'removeChatMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/chat/$roomId/members/${member.accountId}',
);
memberNotifier.reset();
memberNotifier.loadMore();
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
),
final member = data.items[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _ChatMemberRoleSheet(
roomId: roomId,
member: member,
),
).then((value) {
if (value != null) {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
}
});
},
),
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeChatMemberHint'.tr(),
'removeChatMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/chat/$roomId/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
);
},
),
),
],
),

View File

@@ -0,0 +1,180 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'room_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatMemberListNotifierHash() =>
r'f2191a631ba00ae3de39ccac10e4cdd065ffee17';
/// 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));
}
}
abstract class _$ChatMemberListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnChatMember>> {
late final String roomId;
FutureOr<CursorPagingData<SnChatMember>> build(String roomId);
}
/// See also [ChatMemberListNotifier].
@ProviderFor(ChatMemberListNotifier)
const chatMemberListNotifierProvider = ChatMemberListNotifierFamily();
/// See also [ChatMemberListNotifier].
class ChatMemberListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnChatMember>>> {
/// See also [ChatMemberListNotifier].
const ChatMemberListNotifierFamily();
/// See also [ChatMemberListNotifier].
ChatMemberListNotifierProvider call(String roomId) {
return ChatMemberListNotifierProvider(roomId);
}
@override
ChatMemberListNotifierProvider getProviderOverride(
covariant ChatMemberListNotifierProvider provider,
) {
return call(provider.roomId);
}
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'chatMemberListNotifierProvider';
}
/// See also [ChatMemberListNotifier].
class ChatMemberListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
> {
/// See also [ChatMemberListNotifier].
ChatMemberListNotifierProvider(String roomId)
: this._internal(
() => ChatMemberListNotifier()..roomId = roomId,
from: chatMemberListNotifierProvider,
name: r'chatMemberListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatMemberListNotifierHash,
dependencies: ChatMemberListNotifierFamily._dependencies,
allTransitiveDependencies:
ChatMemberListNotifierFamily._allTransitiveDependencies,
roomId: roomId,
);
ChatMemberListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.roomId,
}) : super.internal();
final String roomId;
@override
FutureOr<CursorPagingData<SnChatMember>> runNotifierBuild(
covariant ChatMemberListNotifier notifier,
) {
return notifier.build(roomId);
}
@override
Override overrideWith(ChatMemberListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ChatMemberListNotifierProvider._internal(
() => create()..roomId = roomId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
roomId: roomId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
>
createElement() {
return _ChatMemberListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatMemberListNotifierProvider && other.roomId == roomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, roomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ChatMemberListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnChatMember>> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _ChatMemberListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
>
with ChatMemberListNotifierRef {
_ChatMemberListNotifierProviderElement(super.provider);
@override
String get roomId => (origin as ChatMemberListNotifierProvider).roomId;
}
// 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

@@ -168,12 +168,6 @@ class _ActivityListView extends HookConsumerWidget {
);
}
break;
case 'accounts.check-in':
itemWidget = CheckInActivityWidget(item: item);
break;
case 'accounts.status':
itemWidget = StatusActivityWidget(item: item);
break;
default:
itemWidget = const Placeholder();
}
@@ -196,12 +190,14 @@ class ActivityListNotifier extends _$ActivityListNotifier
@override
Future<CursorPagingData<SnActivity>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final response = await client.get(
'/activities',
queryParameters: {'offset': offset, 'take': take},
queryParameters: {
if (cursor != null) 'reading_cursor': cursor,
'take': take,
},
);
final List<SnActivity> items =
@@ -209,9 +205,9 @@ class ActivityListNotifier extends _$ActivityListNotifier
.map((e) => SnActivity.fromJson(e as Map<String, dynamic>))
.toList();
final total = int.tryParse(response.headers['x-total']?.first ?? '') ?? 0;
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty';
final nextCursor =
items.map((x) => x.createdAt).lastOrNull?.toIso8601String().toString();
return CursorPagingData(
items: items,

View File

@@ -7,7 +7,7 @@ part of 'explore.dart';
// **************************************************************************
String _$activityListNotifierHash() =>
r'8a67d302e828408c7c4cf724d84c2c5958f2dc7e';
r'1baf0bb961bc02bfc8a5b5f515981072c6ce1750';
/// See also [ActivityListNotifier].
@ProviderFor(ActivityListNotifier)

View File

@@ -15,6 +15,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'detail.g.dart';
@@ -250,6 +251,42 @@ class _RealmActionMenu extends HookConsumerWidget {
}
}
@riverpod
class RealmMemberListNotifier extends _$RealmMemberListNotifier
with CursorPagingNotifierMixin<SnRealmMember> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnRealmMember>> build(String realmSlug) async {
return fetch();
}
@override
Future<CursorPagingData<SnRealmMember>> fetch({String? cursor}) async {
final apiClient = ref.read(apiClientProvider);
final offset = cursor != null ? int.parse(cursor) : 0;
final response = await apiClient.get(
'/realms/$realmSlug/members',
queryParameters: {'offset': offset, 'take': _pageSize},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnRealmMember.fromJson(e)).toList();
final hasMore = offset + members.length < total;
final nextCursor = hasMore ? (offset + members.length).toString() : null;
return CursorPagingData(
items: members,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
// Keep the old provider for backward compatibility
final realmMemberStateProvider =
StateNotifierProvider.family<RealmMemberNotifier, RealmMemberState, String>(
(ref, realmSlug) {
@@ -302,13 +339,15 @@ class _RealmMemberListSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
final memberListProvider = realmMemberListNotifierProvider(realmSlug);
// For backward compatibility and to show total count in the header
final memberState = ref.watch(realmMemberStateProvider(realmSlug));
final memberNotifier = ref.read(
realmMemberStateProvider(realmSlug).notifier,
);
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
useEffect(() {
Future(() {
memberNotifier.loadMore();
@@ -329,8 +368,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
'/realms/invites/$realmSlug',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
memberNotifier.reset();
await memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
@@ -362,8 +403,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
},
),
IconButton(
@@ -376,108 +419,103 @@ class _RealmMemberListSheet extends HookConsumerWidget {
),
const Divider(height: 1),
Expanded(
child:
memberState.error != null
? Center(child: Text(memberState.error!))
: ListView.builder(
itemCount: memberState.members.length + 1,
itemBuilder: (context, index) {
if (index == memberState.members.length) {
if (memberState.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
if (memberState.members.length < memberState.total) {
memberNotifier.loadMore(
offset: memberState.members.length,
);
}
return const SizedBox.shrink();
}
child: PagingHelperView(
provider: memberListProvider,
futureRefreshable: memberListProvider.future,
notifierRefreshable: memberListProvider.notifier,
contentBuilder: (data, widgetCount, endItemView) {
return ListView.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == data.items.length) {
return endItemView;
}
final member = memberState.members[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account!.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account!.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account!.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
).then((value) {
if (value != null) {
memberNotifier.reset();
memberNotifier.loadMore();
}
});
},
),
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeRealmMemberHint'.tr(),
'removeRealmMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/realms/$realmSlug/members/${member.accountId}',
);
memberNotifier.reset();
memberNotifier.loadMore();
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
),
final member = data.items[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account!.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account!.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account!.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
).then((value) {
if (value != null) {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
}
});
},
),
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeRealmMemberHint'.tr(),
'removeRealmMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/realms/$realmSlug/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
);
},
),
),
],
),

View File

@@ -148,5 +148,155 @@ class _RealmIdentityProviderElement
String get realmSlug => (origin as RealmIdentityProvider).realmSlug;
}
String _$realmMemberListNotifierHash() =>
r'b2e3eefc62a597f45df9470b2058fdda62f8853f';
abstract class _$RealmMemberListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnRealmMember>> {
late final String realmSlug;
FutureOr<CursorPagingData<SnRealmMember>> build(String realmSlug);
}
/// See also [RealmMemberListNotifier].
@ProviderFor(RealmMemberListNotifier)
const realmMemberListNotifierProvider = RealmMemberListNotifierFamily();
/// See also [RealmMemberListNotifier].
class RealmMemberListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnRealmMember>>> {
/// See also [RealmMemberListNotifier].
const RealmMemberListNotifierFamily();
/// See also [RealmMemberListNotifier].
RealmMemberListNotifierProvider call(String realmSlug) {
return RealmMemberListNotifierProvider(realmSlug);
}
@override
RealmMemberListNotifierProvider getProviderOverride(
covariant RealmMemberListNotifierProvider provider,
) {
return call(provider.realmSlug);
}
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'realmMemberListNotifierProvider';
}
/// See also [RealmMemberListNotifier].
class RealmMemberListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
> {
/// See also [RealmMemberListNotifier].
RealmMemberListNotifierProvider(String realmSlug)
: this._internal(
() => RealmMemberListNotifier()..realmSlug = realmSlug,
from: realmMemberListNotifierProvider,
name: r'realmMemberListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$realmMemberListNotifierHash,
dependencies: RealmMemberListNotifierFamily._dependencies,
allTransitiveDependencies:
RealmMemberListNotifierFamily._allTransitiveDependencies,
realmSlug: realmSlug,
);
RealmMemberListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.realmSlug,
}) : super.internal();
final String realmSlug;
@override
FutureOr<CursorPagingData<SnRealmMember>> runNotifierBuild(
covariant RealmMemberListNotifier notifier,
) {
return notifier.build(realmSlug);
}
@override
Override overrideWith(RealmMemberListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: RealmMemberListNotifierProvider._internal(
() => create()..realmSlug = realmSlug,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
realmSlug: realmSlug,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
>
createElement() {
return _RealmMemberListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is RealmMemberListNotifierProvider &&
other.realmSlug == realmSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, realmSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin RealmMemberListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnRealmMember>> {
/// The parameter `realmSlug` of this provider.
String get realmSlug;
}
class _RealmMemberListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
>
with RealmMemberListNotifierRef {
_RealmMemberListNotifierProviderElement(super.provider);
@override
String get realmSlug => (origin as RealmMemberListNotifierProvider).realmSlug;
}
// 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

@@ -133,54 +133,3 @@ class AccountStatusWidget extends HookConsumerWidget {
).opacity((userStatus.value?.isCustomized ?? false) ? 1 : 0.85);
}
}
class StatusActivityWidget extends StatelessWidget {
final SnActivity item;
const StatusActivityWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
final result = SnAccountStatus.fromJson(item.data);
return Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(
fileId: item.account.profile.picture?.id,
radius: 12,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Symbols.circle, size: 12).padding(top: 1, left: 2),
const Gap(4),
Text('status').fontSize(11).tr(),
],
).opacity(0.85),
Text(
result.clearedAt == null
? 'statusActivityTitle'
: 'statusActivityEndedTitle',
)
.tr(
args: [
item.account.nick,
result.label,
RelativeTime(context).format(result.createdAt),
if (result.clearedAt != null)
RelativeTime(context).format(result.clearedAt!),
],
)
.fontSize(13)
.padding(left: 2),
],
),
),
],
).padding(horizontal: 16, vertical: 12);
}
}

View File

@@ -119,7 +119,7 @@ class ProfilePictureWidget extends ConsumerWidget {
fallbackColor ??
Theme.of(context).colorScheme.onPrimaryContainer,
).center()
: CachedNetworkImage(imageUrl: uri, fit: BoxFit.cover),
: UniversalImage(uri: uri, fit: BoxFit.cover),
),
);
}
@@ -298,7 +298,7 @@ class SplitAvatarWidget extends ConsumerWidget {
return SizedBox(
width: radius,
height: radius,
child: CachedNetworkImage(imageUrl: uri, fit: BoxFit.cover),
child: UniversalImage(uri: uri, fit: BoxFit.cover),
);
}
}

View File

@@ -45,6 +45,16 @@ class UniversalImage extends StatelessWidget {
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: CircularProgressIndicator(value: progress.progress),
);
},
errorWidget: (context, url, error) {
return const Center(
child: Icon(Icons.broken_image, color: Colors.white, size: 16),
);
},
),
],
),

View File

@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@@ -37,26 +36,16 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url');
final token = await getToken(ref.watch(tokenProvider));
final fileStream = DefaultCacheManager().getFileStream(
final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile(
url,
headers: {'Authorization': 'Bearer $token'},
withProgress: true,
authHeaders: {'Authorization': 'AtField $token'},
);
await for (var fileInfo in fileStream) {
if (fileInfo is FileInfo) {
uri = fileInfo.file.path;
break;
}
}
uri = url;
} else {
uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url');
}
if (uri == null) {
showErrorAlert('Failed to open media... $url');
return;
}
_player!.open(Media(uri), play: false);
}

View File

@@ -16,7 +16,6 @@ class PostListNotifier extends _$PostListNotifier
@override
Future<CursorPagingData<SnPost>> build(String? pubName) {
this.pubName = pubName;
return fetch(cursor: null);
}

View File

@@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'58a2d5d9a8f742f0a3a3e224a51a811d43903e0d';
String _$postListNotifierHash() => r'a2a273cbf96393a84a66bd6ae8e88058704f3195';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -9,7 +9,11 @@ class ResponseErrorWidget extends StatelessWidget {
final dynamic error;
final VoidCallback onRetry;
const ResponseErrorWidget({super.key, required this.error, required this.onRetry});
const ResponseErrorWidget({
super.key,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
@@ -36,6 +40,15 @@ class ResponseErrorWidget extends StatelessWidget {
],
),
).center()
else if (error is DioException && error.response != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
error.response.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
).center()
else
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),

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.0.0+103
version: 3.0.0+104
environment:
sdk: ^3.7.2