♻️ Refactored attachment cache
This commit is contained in:
parent
432705c570
commit
359cd94532
@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
@ -17,6 +18,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
late final SnNetworkProvider _sn;
|
||||
late final UserDirectoryProvider _ud;
|
||||
late final WebSocketProvider _ws;
|
||||
late final SnAttachmentProvider _attach;
|
||||
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
@ -24,6 +26,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_ud = context.read<UserDirectoryProvider>();
|
||||
_ws = context.read<WebSocketProvider>();
|
||||
_attach = context.read<SnAttachmentProvider>();
|
||||
}
|
||||
|
||||
bool isPending = true;
|
||||
@ -116,12 +119,28 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
|
||||
final attachmentRid = List<String>.from(
|
||||
message.body['attachments']?.cast<String>() ?? [],
|
||||
);
|
||||
final attachments = await _attach.getMultiple(attachmentRid);
|
||||
message = message.copyWith(
|
||||
preload: SnChatMessagePreload(attachments: attachments),
|
||||
);
|
||||
|
||||
messages.insert(0, message);
|
||||
unconfirmedMessages.add(message.uuid);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _addMessage(SnChatMessage message) async {
|
||||
final attachmentRid = List<String>.from(
|
||||
message.body['attachments']?.cast<String>() ?? [],
|
||||
);
|
||||
final attachments = await _attach.getMultiple(attachmentRid);
|
||||
message = message.copyWith(
|
||||
preload: SnChatMessagePreload(attachments: attachments),
|
||||
);
|
||||
|
||||
final idx = messages.indexWhere((e) => e.uuid == message.uuid);
|
||||
if (idx != -1) {
|
||||
unconfirmedMessages.remove(message.uuid);
|
||||
@ -182,7 +201,8 @@ class ChatMessageController extends ChangeNotifier {
|
||||
'algorithm': 'plain',
|
||||
if (quoteId != null) 'quote_id': quoteId,
|
||||
if (relatedId != null) 'related_id': relatedId,
|
||||
if (attachments != null) 'attachments': attachments,
|
||||
if (attachments != null && attachments.isNotEmpty)
|
||||
'attachments': attachments,
|
||||
};
|
||||
|
||||
// Mock the message locally
|
||||
@ -257,25 +277,41 @@ class ChatMessageController extends ChangeNotifier {
|
||||
int offset, {
|
||||
bool forceLocal = false,
|
||||
}) async {
|
||||
if (_box != null) {
|
||||
// Try retrieve these messages from the local storage
|
||||
if (_box!.length >= take + offset || forceLocal) {
|
||||
return _box!.values.skip(offset).take(take).toList();
|
||||
}
|
||||
late List<SnChatMessage> out;
|
||||
if (_box != null && (_box!.length >= take + offset || forceLocal)) {
|
||||
out = _box!.values.skip(offset).take(take).toList();
|
||||
} else {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/${channel!.keyPath}/events',
|
||||
queryParameters: {
|
||||
'take': take,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
messageTotal = resp.data['count'] as int?;
|
||||
out = List<SnChatMessage>.from(
|
||||
resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [],
|
||||
);
|
||||
_saveMessageToLocal(out);
|
||||
}
|
||||
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/${channel!.keyPath}/events',
|
||||
queryParameters: {
|
||||
'take': take,
|
||||
'offset': offset,
|
||||
},
|
||||
// Preload attachments
|
||||
final attachmentRid = List<String>.from(
|
||||
out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
|
||||
);
|
||||
messageTotal = resp.data['count'] as int?;
|
||||
final out = List<SnChatMessage>.from(
|
||||
resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [],
|
||||
);
|
||||
_saveMessageToLocal(out);
|
||||
final attachments = await _attach.getMultiple(attachmentRid);
|
||||
out = out.reversed
|
||||
.map((ele) => ele.copyWith(
|
||||
preload: SnChatMessagePreload(
|
||||
attachments: attachments
|
||||
.where((e) =>
|
||||
(ele.body['attachments'] as List<dynamic>?)
|
||||
?.contains(e) ??
|
||||
false)
|
||||
.toList(),
|
||||
),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// Preload sender accounts
|
||||
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
|
||||
|
@ -19,6 +19,14 @@ class SnAttachmentProvider {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
}
|
||||
|
||||
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
|
||||
for (final item in items) {
|
||||
if ((item.isAnalyzed && item.isUploaded) || noCheck) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
|
||||
if (!noCache && _cache.containsKey(rid)) {
|
||||
return _cache[rid]!;
|
||||
@ -26,37 +34,48 @@ class SnAttachmentProvider {
|
||||
|
||||
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
|
||||
final out = SnAttachment.fromJson(resp.data);
|
||||
_cache[rid] = out;
|
||||
if (out.isAnalyzed && out.isUploaded) {
|
||||
_cache[rid] = out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<SnAttachment>> getMultiple(List<String> rids,
|
||||
Future<List<SnAttachment?>> getMultiple(List<String> rids,
|
||||
{noCache = false}) async {
|
||||
final pendingFetch =
|
||||
noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList();
|
||||
final result = List<SnAttachment?>.filled(rids.length, null);
|
||||
final Map<String, int> randomMapping = {};
|
||||
for (int i = 0; i < rids.length; i++) {
|
||||
final rid = rids[i];
|
||||
if (noCache || !_cache.containsKey(rid)) {
|
||||
randomMapping[rid] = i;
|
||||
} else {
|
||||
result[i] = _cache[rid]!;
|
||||
}
|
||||
}
|
||||
final pendingFetch = randomMapping.keys;
|
||||
|
||||
if (pendingFetch.isEmpty) {
|
||||
return rids.map((rid) => _cache[rid]!).toList();
|
||||
if (pendingFetch.isNotEmpty) {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/uc/attachments',
|
||||
queryParameters: {
|
||||
'take': pendingFetch.length,
|
||||
'id': pendingFetch.join(','),
|
||||
},
|
||||
);
|
||||
final out = resp.data['data']
|
||||
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
|
||||
.toList();
|
||||
|
||||
for (final item in out) {
|
||||
if (item.isAnalyzed && item.isUploaded) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
result[randomMapping[item.rid]!] = item;
|
||||
}
|
||||
}
|
||||
|
||||
final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: {
|
||||
'take': pendingFetch.length,
|
||||
'id': pendingFetch.join(','),
|
||||
});
|
||||
final out = resp.data['data']
|
||||
.where((e) => e['id'] != 0)
|
||||
.map((e) => SnAttachment.fromJson(e))
|
||||
.toList();
|
||||
|
||||
for (final item in out) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
|
||||
return rids
|
||||
.where((rid) => _cache.containsKey(rid))
|
||||
.map((rid) => _cache[rid]!)
|
||||
.toList();
|
||||
return result;
|
||||
}
|
||||
|
||||
static Map<String, String> mimetypeOverrides = {
|
||||
|
@ -53,7 +53,8 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
preload: SnPostPreload(
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
|
||||
(ele) =>
|
||||
out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
@ -80,7 +80,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
_writeController.addAttachments(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
|
||||
part 'chat.freezed.dart';
|
||||
@ -79,8 +80,22 @@ class SnChatMessage with _$SnChatMessage {
|
||||
@HiveField(8) required SnChannelMember sender,
|
||||
@HiveField(9) required int channelId,
|
||||
@HiveField(10) required int senderId,
|
||||
SnChatMessagePreload? preload,
|
||||
}) = _SnChatMessage;
|
||||
|
||||
factory SnChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnChatMessageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnChatMessagePreload with _$SnChatMessagePreload {
|
||||
const SnChatMessagePreload._();
|
||||
|
||||
@HiveType(typeId: 5)
|
||||
const factory SnChatMessagePreload({
|
||||
@HiveField(0) List<SnAttachment?>? attachments,
|
||||
}) = _SnChatMessagePreload;
|
||||
|
||||
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnChatMessagePreloadFromJson(json);
|
||||
}
|
||||
|
@ -1083,6 +1083,7 @@ mixin _$SnChatMessage {
|
||||
int get channelId => throw _privateConstructorUsedError;
|
||||
@HiveField(10)
|
||||
int get senderId => throw _privateConstructorUsedError;
|
||||
SnChatMessagePreload? get preload => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnChatMessage to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@ -1111,10 +1112,12 @@ abstract class $SnChatMessageCopyWith<$Res> {
|
||||
@HiveField(7) SnChannel channel,
|
||||
@HiveField(8) SnChannelMember sender,
|
||||
@HiveField(9) int channelId,
|
||||
@HiveField(10) int senderId});
|
||||
@HiveField(10) int senderId,
|
||||
SnChatMessagePreload? preload});
|
||||
|
||||
$SnChannelCopyWith<$Res> get channel;
|
||||
$SnChannelMemberCopyWith<$Res> get sender;
|
||||
$SnChatMessagePreloadCopyWith<$Res>? get preload;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -1143,6 +1146,7 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage>
|
||||
Object? sender = null,
|
||||
Object? channelId = null,
|
||||
Object? senderId = null,
|
||||
Object? preload = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
@ -1189,6 +1193,10 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage>
|
||||
? _value.senderId
|
||||
: senderId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
preload: freezed == preload
|
||||
? _value.preload
|
||||
: preload // ignore: cast_nullable_to_non_nullable
|
||||
as SnChatMessagePreload?,
|
||||
) as $Val);
|
||||
}
|
||||
|
||||
@ -1211,6 +1219,20 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage>
|
||||
return _then(_value.copyWith(sender: value) as $Val);
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a copy of SnChatMessage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnChatMessagePreloadCopyWith<$Res>? get preload {
|
||||
if (_value.preload == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnChatMessagePreloadCopyWith<$Res>(_value.preload!, (value) {
|
||||
return _then(_value.copyWith(preload: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -1232,12 +1254,15 @@ abstract class _$$SnChatMessageImplCopyWith<$Res>
|
||||
@HiveField(7) SnChannel channel,
|
||||
@HiveField(8) SnChannelMember sender,
|
||||
@HiveField(9) int channelId,
|
||||
@HiveField(10) int senderId});
|
||||
@HiveField(10) int senderId,
|
||||
SnChatMessagePreload? preload});
|
||||
|
||||
@override
|
||||
$SnChannelCopyWith<$Res> get channel;
|
||||
@override
|
||||
$SnChannelMemberCopyWith<$Res> get sender;
|
||||
@override
|
||||
$SnChatMessagePreloadCopyWith<$Res>? get preload;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -1264,6 +1289,7 @@ class __$$SnChatMessageImplCopyWithImpl<$Res>
|
||||
Object? sender = null,
|
||||
Object? channelId = null,
|
||||
Object? senderId = null,
|
||||
Object? preload = freezed,
|
||||
}) {
|
||||
return _then(_$SnChatMessageImpl(
|
||||
id: null == id
|
||||
@ -1310,6 +1336,10 @@ class __$$SnChatMessageImplCopyWithImpl<$Res>
|
||||
? _value.senderId
|
||||
: senderId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
preload: freezed == preload
|
||||
? _value.preload
|
||||
: preload // ignore: cast_nullable_to_non_nullable
|
||||
as SnChatMessagePreload?,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -1329,7 +1359,8 @@ class _$SnChatMessageImpl extends _SnChatMessage {
|
||||
@HiveField(7) required this.channel,
|
||||
@HiveField(8) required this.sender,
|
||||
@HiveField(9) required this.channelId,
|
||||
@HiveField(10) required this.senderId})
|
||||
@HiveField(10) required this.senderId,
|
||||
this.preload})
|
||||
: _body = body,
|
||||
super._();
|
||||
|
||||
@ -1375,10 +1406,12 @@ class _$SnChatMessageImpl extends _SnChatMessage {
|
||||
@override
|
||||
@HiveField(10)
|
||||
final int senderId;
|
||||
@override
|
||||
final SnChatMessagePreload? preload;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId)';
|
||||
return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId, preload: $preload)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -1401,7 +1434,8 @@ class _$SnChatMessageImpl extends _SnChatMessage {
|
||||
(identical(other.channelId, channelId) ||
|
||||
other.channelId == channelId) &&
|
||||
(identical(other.senderId, senderId) ||
|
||||
other.senderId == senderId));
|
||||
other.senderId == senderId) &&
|
||||
(identical(other.preload, preload) || other.preload == preload));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -1418,7 +1452,8 @@ class _$SnChatMessageImpl extends _SnChatMessage {
|
||||
channel,
|
||||
sender,
|
||||
channelId,
|
||||
senderId);
|
||||
senderId,
|
||||
preload);
|
||||
|
||||
/// Create a copy of SnChatMessage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -1448,7 +1483,8 @@ abstract class _SnChatMessage extends SnChatMessage {
|
||||
@HiveField(7) required final SnChannel channel,
|
||||
@HiveField(8) required final SnChannelMember sender,
|
||||
@HiveField(9) required final int channelId,
|
||||
@HiveField(10) required final int senderId}) = _$SnChatMessageImpl;
|
||||
@HiveField(10) required final int senderId,
|
||||
final SnChatMessagePreload? preload}) = _$SnChatMessageImpl;
|
||||
const _SnChatMessage._() : super._();
|
||||
|
||||
factory _SnChatMessage.fromJson(Map<String, dynamic> json) =
|
||||
@ -1487,6 +1523,8 @@ abstract class _SnChatMessage extends SnChatMessage {
|
||||
@override
|
||||
@HiveField(10)
|
||||
int get senderId;
|
||||
@override
|
||||
SnChatMessagePreload? get preload;
|
||||
|
||||
/// Create a copy of SnChatMessage
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -1495,3 +1533,174 @@ abstract class _SnChatMessage extends SnChatMessage {
|
||||
_$$SnChatMessageImplCopyWith<_$SnChatMessageImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
SnChatMessagePreload _$SnChatMessagePreloadFromJson(Map<String, dynamic> json) {
|
||||
return _SnChatMessagePreload.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnChatMessagePreload {
|
||||
@HiveField(0)
|
||||
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnChatMessagePreload to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of SnChatMessagePreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$SnChatMessagePreloadCopyWith<SnChatMessagePreload> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SnChatMessagePreloadCopyWith<$Res> {
|
||||
factory $SnChatMessagePreloadCopyWith(SnChatMessagePreload value,
|
||||
$Res Function(SnChatMessagePreload) then) =
|
||||
_$SnChatMessagePreloadCopyWithImpl<$Res, SnChatMessagePreload>;
|
||||
@useResult
|
||||
$Res call({@HiveField(0) List<SnAttachment?>? attachments});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SnChatMessagePreloadCopyWithImpl<$Res,
|
||||
$Val extends SnChatMessagePreload>
|
||||
implements $SnChatMessagePreloadCopyWith<$Res> {
|
||||
_$SnChatMessagePreloadCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SnChatMessagePreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? attachments = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
attachments: freezed == attachments
|
||||
? _value.attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnAttachment?>?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$SnChatMessagePreloadImplCopyWith<$Res>
|
||||
implements $SnChatMessagePreloadCopyWith<$Res> {
|
||||
factory _$$SnChatMessagePreloadImplCopyWith(_$SnChatMessagePreloadImpl value,
|
||||
$Res Function(_$SnChatMessagePreloadImpl) then) =
|
||||
__$$SnChatMessagePreloadImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({@HiveField(0) List<SnAttachment?>? attachments});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$SnChatMessagePreloadImplCopyWithImpl<$Res>
|
||||
extends _$SnChatMessagePreloadCopyWithImpl<$Res, _$SnChatMessagePreloadImpl>
|
||||
implements _$$SnChatMessagePreloadImplCopyWith<$Res> {
|
||||
__$$SnChatMessagePreloadImplCopyWithImpl(_$SnChatMessagePreloadImpl _value,
|
||||
$Res Function(_$SnChatMessagePreloadImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of SnChatMessagePreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? attachments = freezed,
|
||||
}) {
|
||||
return _then(_$SnChatMessagePreloadImpl(
|
||||
attachments: freezed == attachments
|
||||
? _value._attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnAttachment?>?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
@HiveType(typeId: 5)
|
||||
class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
|
||||
const _$SnChatMessagePreloadImpl(
|
||||
{@HiveField(0) final List<SnAttachment?>? attachments})
|
||||
: _attachments = attachments,
|
||||
super._();
|
||||
|
||||
factory _$SnChatMessagePreloadImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$SnChatMessagePreloadImplFromJson(json);
|
||||
|
||||
final List<SnAttachment?>? _attachments;
|
||||
@override
|
||||
@HiveField(0)
|
||||
List<SnAttachment?>? get attachments {
|
||||
final value = _attachments;
|
||||
if (value == null) return null;
|
||||
if (_attachments is EqualUnmodifiableListView) return _attachments;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnChatMessagePreload(attachments: $attachments)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$SnChatMessagePreloadImpl &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._attachments, _attachments));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType, const DeepCollectionEquality().hash(_attachments));
|
||||
|
||||
/// Create a copy of SnChatMessagePreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl>
|
||||
get copyWith =>
|
||||
__$$SnChatMessagePreloadImplCopyWithImpl<_$SnChatMessagePreloadImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$SnChatMessagePreloadImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _SnChatMessagePreload extends SnChatMessagePreload {
|
||||
const factory _SnChatMessagePreload(
|
||||
{@HiveField(0) final List<SnAttachment?>? attachments}) =
|
||||
_$SnChatMessagePreloadImpl;
|
||||
const _SnChatMessagePreload._() : super._();
|
||||
|
||||
factory _SnChatMessagePreload.fromJson(Map<String, dynamic> json) =
|
||||
_$SnChatMessagePreloadImpl.fromJson;
|
||||
|
||||
@override
|
||||
@HiveField(0)
|
||||
List<SnAttachment?>? get attachments;
|
||||
|
||||
/// Create a copy of SnChatMessagePreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl>
|
||||
get copyWith => throw _privateConstructorUsedError;
|
||||
}
|
||||
|
@ -204,6 +204,41 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> {
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SnChatMessagePreloadImplAdapter
|
||||
extends TypeAdapter<_$SnChatMessagePreloadImpl> {
|
||||
@override
|
||||
final int typeId = 5;
|
||||
|
||||
@override
|
||||
_$SnChatMessagePreloadImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$SnChatMessagePreloadImpl(
|
||||
attachments: (fields[0] as List?)?.cast<SnAttachment?>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$SnChatMessagePreloadImpl obj) {
|
||||
writer
|
||||
..writeByte(1)
|
||||
..writeByte(0)
|
||||
..write(obj.attachments);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SnChatMessagePreloadImplAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
@ -309,6 +344,10 @@ _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
|
||||
sender: SnChannelMember.fromJson(json['sender'] as Map<String, dynamic>),
|
||||
channelId: (json['channel_id'] as num).toInt(),
|
||||
senderId: (json['sender_id'] as num).toInt(),
|
||||
preload: json['preload'] == null
|
||||
? null
|
||||
: SnChatMessagePreload.fromJson(
|
||||
json['preload'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
|
||||
@ -324,4 +363,21 @@ Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
|
||||
'sender': instance.sender.toJson(),
|
||||
'channel_id': instance.channelId,
|
||||
'sender_id': instance.senderId,
|
||||
'preload': instance.preload?.toJson(),
|
||||
};
|
||||
|
||||
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnChatMessagePreloadImpl(
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => e == null
|
||||
? null
|
||||
: SnAttachment.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
|
||||
_$SnChatMessagePreloadImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
|
||||
};
|
||||
|
@ -53,7 +53,7 @@ class SnPost with _$SnPost {
|
||||
@freezed
|
||||
class SnPostPreload with _$SnPostPreload {
|
||||
const factory SnPostPreload({
|
||||
required List<SnAttachment>? attachments,
|
||||
required List<SnAttachment?>? attachments,
|
||||
}) = _SnPostPreload;
|
||||
|
||||
factory SnPostPreload.fromJson(Map<String, Object?> json) =>
|
||||
|
@ -953,7 +953,7 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnPostPreload {
|
||||
List<SnAttachment>? get attachments => throw _privateConstructorUsedError;
|
||||
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnPostPreload to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@ -971,7 +971,7 @@ abstract class $SnPostPreloadCopyWith<$Res> {
|
||||
SnPostPreload value, $Res Function(SnPostPreload) then) =
|
||||
_$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
|
||||
@useResult
|
||||
$Res call({List<SnAttachment>? attachments});
|
||||
$Res call({List<SnAttachment?>? attachments});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -995,7 +995,7 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload>
|
||||
attachments: freezed == attachments
|
||||
? _value.attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnAttachment>?,
|
||||
as List<SnAttachment?>?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
@ -1008,7 +1008,7 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
|
||||
__$$SnPostPreloadImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({List<SnAttachment>? attachments});
|
||||
$Res call({List<SnAttachment?>? attachments});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -1030,7 +1030,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
|
||||
attachments: freezed == attachments
|
||||
? _value._attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnAttachment>?,
|
||||
as List<SnAttachment?>?,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -1038,15 +1038,15 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$SnPostPreloadImpl implements _SnPostPreload {
|
||||
const _$SnPostPreloadImpl({required final List<SnAttachment>? attachments})
|
||||
const _$SnPostPreloadImpl({required final List<SnAttachment?>? attachments})
|
||||
: _attachments = attachments;
|
||||
|
||||
factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$SnPostPreloadImplFromJson(json);
|
||||
|
||||
final List<SnAttachment>? _attachments;
|
||||
final List<SnAttachment?>? _attachments;
|
||||
@override
|
||||
List<SnAttachment>? get attachments {
|
||||
List<SnAttachment?>? get attachments {
|
||||
final value = _attachments;
|
||||
if (value == null) return null;
|
||||
if (_attachments is EqualUnmodifiableListView) return _attachments;
|
||||
@ -1091,13 +1091,13 @@ class _$SnPostPreloadImpl implements _SnPostPreload {
|
||||
|
||||
abstract class _SnPostPreload implements SnPostPreload {
|
||||
const factory _SnPostPreload(
|
||||
{required final List<SnAttachment>? attachments}) = _$SnPostPreloadImpl;
|
||||
{required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl;
|
||||
|
||||
factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
|
||||
_$SnPostPreloadImpl.fromJson;
|
||||
|
||||
@override
|
||||
List<SnAttachment>? get attachments;
|
||||
List<SnAttachment?>? get attachments;
|
||||
|
||||
/// Create a copy of SnPostPreload
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
|
@ -103,13 +103,15 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
|
||||
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnPostPreloadImpl(
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => SnAttachment.fromJson(e as Map<String, dynamic>))
|
||||
?.map((e) => e == null
|
||||
? null
|
||||
: SnAttachment.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'attachments': instance.attachments?.map((e) => e.toJson()).toList(),
|
||||
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
|
||||
};
|
||||
|
||||
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(
|
||||
|
@ -14,7 +14,7 @@ import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AttachmentItem extends StatelessWidget {
|
||||
final SnAttachment data;
|
||||
final SnAttachment? data;
|
||||
final bool isExpandable;
|
||||
const AttachmentItem({
|
||||
super.key,
|
||||
@ -23,15 +23,19 @@ class AttachmentItem extends StatelessWidget {
|
||||
});
|
||||
|
||||
Widget _buildContent(BuildContext context, String heroTag) {
|
||||
final tp = data.mimetype.split('/').firstOrNull;
|
||||
if (data == null) {
|
||||
return const Icon(Symbols.cancel).center();
|
||||
}
|
||||
|
||||
final tp = data!.mimetype.split('/').firstOrNull;
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
switch (tp) {
|
||||
case 'image':
|
||||
return Hero(
|
||||
tag: 'attachment-${data.rid}-$heroTag',
|
||||
tag: 'attachment-${data!.rid}-$heroTag',
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data.rid),
|
||||
key: Key('attachment-${data.rid}-$heroTag'),
|
||||
sn.getAttachmentUrl(data!.rid),
|
||||
key: Key('attachment-${data!.rid}-$heroTag'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
@ -45,7 +49,7 @@ class AttachmentItem extends StatelessWidget {
|
||||
final uuid = Uuid();
|
||||
final heroTag = uuid.v4();
|
||||
|
||||
if (data.isMature) {
|
||||
if (data!.isMature) {
|
||||
return _AttachmentItemSensitiveBlur(
|
||||
child: _buildContent(context, heroTag),
|
||||
);
|
||||
@ -56,7 +60,7 @@ class AttachmentItem extends StatelessWidget {
|
||||
child: _buildContent(context, heroTag),
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
AttachmentDetailPopup(data: data, heroTag: heroTag),
|
||||
AttachmentDetailPopup(data: data!, heroTag: heroTag),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
|
@ -6,7 +6,7 @@ import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
|
||||
class AttachmentList extends StatelessWidget {
|
||||
final List<SnAttachment> data;
|
||||
final List<SnAttachment?> data;
|
||||
final bool? bordered;
|
||||
final double? maxHeight;
|
||||
final EdgeInsets? listPadding;
|
||||
@ -46,7 +46,7 @@ class AttachmentList extends StatelessWidget {
|
||||
borderRadius: kDefaultRadius,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
|
||||
aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: kDefaultRadius,
|
||||
child: AttachmentItem(data: data[0], isExpandable: true),
|
||||
@ -62,7 +62,7 @@ class AttachmentList extends StatelessWidget {
|
||||
border: Border(top: borderSide, bottom: borderSide),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
|
||||
aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1,
|
||||
child: AttachmentItem(data: data[0], isExpandable: true),
|
||||
),
|
||||
);
|
||||
@ -86,7 +86,7 @@ class AttachmentList extends StatelessWidget {
|
||||
borderRadius: kDefaultRadius,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1,
|
||||
aspectRatio: data[idx]?.metadata['ratio']?.toDouble() ?? 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: kDefaultRadius,
|
||||
child:
|
||||
|
@ -6,6 +6,7 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_list.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
@ -63,6 +64,13 @@ class ChatMessage extends StatelessWidget {
|
||||
content: data.body['text'],
|
||||
isAutoWarp: true,
|
||||
),
|
||||
if (data.preload?.attachments?.isNotEmpty ?? false)
|
||||
AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
bordered: true,
|
||||
maxHeight: 520,
|
||||
listPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
if (!hasMerged) const Gap(8),
|
||||
],
|
||||
),
|
||||
|
@ -1,9 +1,15 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
|
||||
|
||||
class ChatMessageInput extends StatefulWidget {
|
||||
final ChatMessageController controller;
|
||||
@ -14,15 +20,83 @@ class ChatMessageInput extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
bool _isBusy = false;
|
||||
double? _progress;
|
||||
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
void _sendMessage() {
|
||||
Future<void> _sendMessage() async {
|
||||
if (_isBusy) return;
|
||||
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
for (int i = 0; i < _attachments.length; i++) {
|
||||
final media = _attachments[i];
|
||||
if (media.attachment != null) continue; // Already uploaded, skip
|
||||
if (media.isEmpty) continue; // Nothing to do, skip
|
||||
|
||||
final place = await attach.chunkedUploadInitialize(
|
||||
(await media.length())!,
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
// Calculate overall progress for attachments
|
||||
setState(() {
|
||||
progress = (i + progress) / _attachments.length;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
_attachments[i] = PostWriteMedia(item);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = false);
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
}
|
||||
|
||||
attach.putCache(
|
||||
_attachments.where((e) => e.attachment != null).map((e) => e.attachment!),
|
||||
);
|
||||
|
||||
// Send the message
|
||||
// NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type
|
||||
widget.controller.sendMessage(
|
||||
'messages.new',
|
||||
_contentController.text,
|
||||
attachments: _attachments
|
||||
.where((e) => e.attachment != null)
|
||||
.map((e) => e.attachment!.rid)
|
||||
.toList(),
|
||||
);
|
||||
_contentController.clear();
|
||||
_attachments.clear();
|
||||
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
|
||||
final List<PostWriteMedia> _attachments = List.empty(growable: true);
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
void _selectMedia() async {
|
||||
final result = await _imagePicker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
_attachments.addAll(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -37,6 +111,33 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isBusy && _progress != null)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _progress),
|
||||
duration: Duration(milliseconds: 300),
|
||||
builder: (context, value, _) =>
|
||||
LinearProgressIndicator(value: value, minHeight: 2),
|
||||
)
|
||||
else if (_isBusy)
|
||||
const LinearProgressIndicator(value: null, minHeight: 2),
|
||||
Padding(
|
||||
padding: _attachments.isNotEmpty
|
||||
? const EdgeInsets.only(top: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: PostMediaPendingListRaw(
|
||||
attachments: _attachments,
|
||||
isBusy: _isBusy,
|
||||
onUpdate: (idx, updatedMedia) async {
|
||||
setState(() => _attachments[idx] = updatedMedia);
|
||||
},
|
||||
onRemove: (idx) async {
|
||||
setState(() => _attachments.removeAt(idx));
|
||||
},
|
||||
onUpdateBusy: (state) => setState(() => _isBusy = state),
|
||||
).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
|
||||
const Duration(milliseconds: 300),
|
||||
Curves.fastEaseInToSlowEaseOut),
|
||||
),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
@ -53,6 +154,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) {
|
||||
if (_isBusy) return;
|
||||
_sendMessage();
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
@ -60,7 +162,14 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
onPressed: _sendMessage,
|
||||
onPressed: _isBusy ? null : _selectMedia,
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _isBusy ? null : _sendMessage,
|
||||
icon: Icon(
|
||||
Symbols.send,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
|
@ -61,7 +61,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
|
||||
preload: SnPostPreload(
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
|
||||
(ele) =>
|
||||
out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
@ -18,54 +18,22 @@ import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class PostMediaPendingList extends StatelessWidget {
|
||||
final PostWriteController controller;
|
||||
|
||||
const PostMediaPendingList({super.key, required this.controller});
|
||||
|
||||
void _cropImage(BuildContext context, int idx) async {
|
||||
final media = controller.attachments[idx];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
// ignore: use_build_context_synchronously
|
||||
imageProvider: media.getImageProvider(context)!,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
// ignore: use_build_context_synchronously
|
||||
imageProvider: media.getImageProvider(context)!,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
Future<void> _handleUpdate(int idx, PostWriteMedia updatedMedia) async {
|
||||
controller.setIsBusy(true);
|
||||
|
||||
final rawBytes =
|
||||
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
controller.setAttachmentAt(
|
||||
idx,
|
||||
PostWriteMedia.fromBytes(rawBytes, media.name, media.type),
|
||||
);
|
||||
|
||||
controller.setIsBusy(false);
|
||||
try {
|
||||
controller.setAttachmentAt(idx, updatedMedia);
|
||||
} finally {
|
||||
controller.setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteAttachment(BuildContext context, int idx) async {
|
||||
final media = controller.attachments[idx];
|
||||
if (media.attachment == null) return;
|
||||
|
||||
Future<void> _handleRemove(int idx) async {
|
||||
controller.setIsBusy(true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}');
|
||||
controller.removeAttachmentAt(idx);
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
controller.setIsBusy(false);
|
||||
}
|
||||
@ -73,108 +41,180 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
itemCount: controller.attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final media = controller.attachments[idx];
|
||||
return ContextMenuRegion(
|
||||
contextMenu: ContextMenu(
|
||||
entries: [
|
||||
if (media.type == PostWriteMediaType.image &&
|
||||
media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'preview'.tr(),
|
||||
icon: Symbols.preview,
|
||||
onSelected: () {
|
||||
context.pushTransparentRoute(
|
||||
AttachmentDetailPopup(data: media.attachment!),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (media.type == PostWriteMediaType.image &&
|
||||
media.attachment == null)
|
||||
MenuItem(
|
||||
label: 'crop'.tr(),
|
||||
icon: Symbols.crop,
|
||||
onSelected: () => _cropImage(context, idx),
|
||||
),
|
||||
if (media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'delete'.tr(),
|
||||
icon: Symbols.delete,
|
||||
onSelected: controller.isBusy
|
||||
? null
|
||||
: () => _deleteAttachment(context, idx),
|
||||
),
|
||||
if (media.attachment == null)
|
||||
MenuItem(
|
||||
label: 'delete'.tr(),
|
||||
icon: Symbols.delete,
|
||||
onSelected: () {
|
||||
controller.removeAttachmentAt(idx);
|
||||
},
|
||||
)
|
||||
else
|
||||
MenuItem(
|
||||
label: 'unlink'.tr(),
|
||||
icon: Symbols.link_off,
|
||||
onSelected: () {
|
||||
controller.removeAttachmentAt(idx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
PostWriteMediaType.image =>
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio)
|
||||
.round(),
|
||||
height:
|
||||
(constraints.maxHeight * devicePixelRatio)
|
||||
.round(),
|
||||
)!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
return PostMediaPendingListRaw(
|
||||
attachments: controller.attachments,
|
||||
isBusy: controller.isBusy,
|
||||
onUpdate: (idx, updatedMedia) => _handleUpdate(idx, updatedMedia),
|
||||
onRemove: (idx) => _handleRemove(idx),
|
||||
onUpdateBusy: (state) => controller.setIsBusy(state),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostMediaPendingListRaw extends StatelessWidget {
|
||||
final List<PostWriteMedia> attachments;
|
||||
final bool isBusy;
|
||||
final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
|
||||
final Future<void> Function(int idx)? onRemove;
|
||||
final void Function(bool state)? onUpdateBusy;
|
||||
|
||||
const PostMediaPendingListRaw({
|
||||
super.key,
|
||||
required this.attachments,
|
||||
required this.isBusy,
|
||||
this.onUpdate,
|
||||
this.onRemove,
|
||||
this.onUpdateBusy,
|
||||
});
|
||||
|
||||
Future<void> _cropImage(BuildContext context, int idx) async {
|
||||
final media = attachments[idx];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
context,
|
||||
imageProvider: media.getImageProvider(context)!,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
context,
|
||||
imageProvider: media.getImageProvider(context)!,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
final rawBytes =
|
||||
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
|
||||
if (onUpdate != null) {
|
||||
final updatedMedia = PostWriteMedia.fromBytes(
|
||||
rawBytes,
|
||||
media.name,
|
||||
media.type,
|
||||
);
|
||||
await onUpdate!(idx, updatedMedia);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAttachment(BuildContext context, int idx) async {
|
||||
final media = attachments[idx];
|
||||
if (media.attachment == null) return;
|
||||
|
||||
try {
|
||||
onUpdateBusy?.call(true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}');
|
||||
onRemove!(idx);
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
onUpdateBusy?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final media = attachments[idx];
|
||||
return ContextMenuRegion(
|
||||
contextMenu: ContextMenu(
|
||||
entries: [
|
||||
if (media.type == PostWriteMediaType.image &&
|
||||
media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'preview'.tr(),
|
||||
icon: Symbols.preview,
|
||||
onSelected: () {
|
||||
context.pushTransparentRoute(
|
||||
AttachmentDetailPopup(data: media.attachment!),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (media.type == PostWriteMediaType.image &&
|
||||
media.attachment == null)
|
||||
MenuItem(
|
||||
label: 'crop'.tr(),
|
||||
icon: Symbols.crop,
|
||||
onSelected: () => _cropImage(context, idx),
|
||||
),
|
||||
if (media.attachment != null && onRemove != null)
|
||||
MenuItem(
|
||||
label: 'delete'.tr(),
|
||||
icon: Symbols.delete,
|
||||
onSelected:
|
||||
isBusy ? null : () => _deleteAttachment(context, idx),
|
||||
),
|
||||
if (media.attachment == null && onRemove != null)
|
||||
MenuItem(
|
||||
label: 'delete'.tr(),
|
||||
icon: Symbols.delete,
|
||||
onSelected: () {
|
||||
onRemove!(idx);
|
||||
},
|
||||
)
|
||||
else if (onRemove != null)
|
||||
MenuItem(
|
||||
label: 'unlink'.tr(),
|
||||
icon: Symbols.link_off,
|
||||
onSelected: () {
|
||||
onRemove!(idx);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
PostWriteMediaType.image =>
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio)
|
||||
.round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio)
|
||||
.round(),
|
||||
)!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user