feat: история изменения сообщений
This commit is contained in:
@@ -538,6 +538,19 @@ class ChatsModule {
|
||||
mergedPayload[entry.key.toString()] = entry.value;
|
||||
}
|
||||
final newRow = Map<String, dynamic>.from(existing);
|
||||
if (KometSettings.viewRedacted.value) {
|
||||
final oldText = existing['text']?.toString();
|
||||
if ((oldText ?? '') != (msgText ?? '') &&
|
||||
oldText != null &&
|
||||
oldText.isNotEmpty) {
|
||||
final history = CachedMessage.appendEditHistory(
|
||||
CachedMessage.parseEditHistory(existing['edit_history']),
|
||||
oldText,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
newRow['edit_history'] = jsonEncode(history);
|
||||
}
|
||||
}
|
||||
newRow['text'] = msgText;
|
||||
newRow['status'] = status;
|
||||
newRow['payload'] = jsonEncode(mergedPayload);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
|
||||
class ComplaintReason {
|
||||
final int reasonId;
|
||||
final String reasonTitle;
|
||||
|
||||
const ComplaintReason({required this.reasonId, required this.reasonTitle});
|
||||
}
|
||||
|
||||
class ComplaintsModule {
|
||||
static Map<int, List<ComplaintReason>>? _cache;
|
||||
|
||||
static Future<Map<int, List<ComplaintReason>>> fetchReasons(Api api) async {
|
||||
final cached = _cache;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final response = await api.sendRequest(
|
||||
Opcode.complainReasonsGet,
|
||||
{'complainSync': 0},
|
||||
);
|
||||
if (!response.isOk) return cached ?? const {};
|
||||
|
||||
final payload = response.payload;
|
||||
if (payload is! Map) return const {};
|
||||
|
||||
final complains = payload['complains'];
|
||||
final map = <int, List<ComplaintReason>>{};
|
||||
if (complains is List) {
|
||||
for (final entry in complains) {
|
||||
if (entry is! Map) continue;
|
||||
final typeId = entry['typeId'];
|
||||
final reasons = entry['reasons'];
|
||||
if (typeId is! int || reasons is! List) continue;
|
||||
map[typeId] = reasons
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(r) => ComplaintReason(
|
||||
reasonId: r['reasonId'] is int ? r['reasonId'] as int : 0,
|
||||
reasonTitle: r['reasonTitle']?.toString() ?? '',
|
||||
),
|
||||
)
|
||||
.where((r) => r.reasonId != 0)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
_cache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
static Future<List<ComplaintReason>> reasonsFor(Api api, int typeId) async {
|
||||
final map = await fetchReasons(api);
|
||||
final forType = map[typeId];
|
||||
if (forType != null && forType.isNotEmpty) return forType;
|
||||
for (final list in map.values) {
|
||||
if (list.isNotEmpty) return list;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
static Future<bool> sendComplaint(
|
||||
Api api, {
|
||||
required int reasonId,
|
||||
required int typeId,
|
||||
required List<int> ids,
|
||||
required int parentId,
|
||||
}) async {
|
||||
final response = await api.sendRequest(Opcode.complain, {
|
||||
'reasonId': reasonId,
|
||||
'typeId': typeId,
|
||||
'ids': ids,
|
||||
'parentId': parentId,
|
||||
});
|
||||
if (!response.isOk) return false;
|
||||
final payload = response.payload;
|
||||
return payload is Map && payload['success'] == true;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/config/komet_settings.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
@@ -365,6 +366,7 @@ class CachedMessage {
|
||||
final List<MessageAttachment>? attachments;
|
||||
final bool isControl;
|
||||
final bool deleted;
|
||||
final List<Map<String, dynamic>>? editHistory;
|
||||
|
||||
const CachedMessage({
|
||||
required this.id,
|
||||
@@ -378,12 +380,14 @@ class CachedMessage {
|
||||
this.attachments,
|
||||
this.isControl = false,
|
||||
this.deleted = false,
|
||||
this.editHistory,
|
||||
});
|
||||
|
||||
CachedMessage copyWith({
|
||||
String? status,
|
||||
bool? deleted,
|
||||
List<MessageAttachment>? attachments,
|
||||
List<Map<String, dynamic>>? editHistory,
|
||||
}) => CachedMessage(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
@@ -396,8 +400,39 @@ class CachedMessage {
|
||||
attachments: attachments ?? this.attachments,
|
||||
isControl: isControl,
|
||||
deleted: deleted ?? this.deleted,
|
||||
editHistory: editHistory ?? this.editHistory,
|
||||
);
|
||||
|
||||
static List<Map<String, dynamic>>? parseEditHistory(dynamic raw) {
|
||||
if (raw is! String || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
final list = decoded
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
return list.isEmpty ? null : list;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> appendEditHistory(
|
||||
List<Map<String, dynamic>>? current,
|
||||
String? oldText,
|
||||
int time,
|
||||
) {
|
||||
final list = current != null
|
||||
? List<Map<String, dynamic>>.from(current)
|
||||
: <Map<String, dynamic>>[];
|
||||
if (list.isNotEmpty && (list.last['text'] as String?) == oldText) {
|
||||
return list;
|
||||
}
|
||||
list.add({'text': oldText, 'time': time});
|
||||
return list;
|
||||
}
|
||||
|
||||
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
|
||||
Map<String, dynamic>? payload;
|
||||
final payloadRaw = row['payload'];
|
||||
@@ -449,6 +484,7 @@ class CachedMessage {
|
||||
deleted: row['deleted'] is int
|
||||
? row['deleted'] == 1
|
||||
: row['deleted']?.toString() == '1',
|
||||
editHistory: parseEditHistory(row['edit_history']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,6 +524,7 @@ class CachedMessage {
|
||||
'status': status,
|
||||
'payload': payload != null ? jsonEncode(payload) : null,
|
||||
'deleted': deleted ? 1 : 0,
|
||||
'edit_history': editHistory != null ? jsonEncode(editHistory) : null,
|
||||
};
|
||||
|
||||
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
|
||||
@@ -551,7 +588,6 @@ class MessagesModule {
|
||||
if (messagesData is! List) return [];
|
||||
|
||||
final List<CachedMessage> results = [];
|
||||
final List<Map<String, dynamic>> rows = [];
|
||||
|
||||
for (var i = 0; i < messagesData.length; i++) {
|
||||
final m = messagesData[i];
|
||||
@@ -560,7 +596,6 @@ class MessagesModule {
|
||||
final msg = _parseMessage(m.cast<dynamic, dynamic>(), accountId, chatId);
|
||||
if (msg != null) {
|
||||
results.add(msg);
|
||||
rows.add(msg.toDbRow());
|
||||
}
|
||||
|
||||
if (i > 0 && i % 20 == 0) {
|
||||
@@ -568,15 +603,57 @@ class MessagesModule {
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
final toSave = KometSettings.viewRedacted.value && results.isNotEmpty
|
||||
? await _mergeEditHistory(accountId, chatId, results)
|
||||
: results;
|
||||
|
||||
if (toSave.isNotEmpty) {
|
||||
try {
|
||||
await AppDatabase.saveMessages(rows);
|
||||
await AppDatabase.saveMessages(
|
||||
toSave.map((m) => m.toDbRow()).toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e('saveMessages error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return toSave;
|
||||
}
|
||||
|
||||
Future<List<CachedMessage>> _mergeEditHistory(
|
||||
int accountId,
|
||||
int chatId,
|
||||
List<CachedMessage> serverMessages,
|
||||
) async {
|
||||
final cachedRows = await AppDatabase.loadMessagesByIds(
|
||||
accountId,
|
||||
chatId,
|
||||
serverMessages.map((m) => m.id).toList(),
|
||||
);
|
||||
final byId = <String, Map<String, dynamic>>{};
|
||||
for (final row in cachedRows) {
|
||||
final id = row['id']?.toString();
|
||||
if (id != null) byId[id] = row;
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final out = <CachedMessage>[];
|
||||
for (final msg in serverMessages) {
|
||||
final existing = byId[msg.id];
|
||||
if (existing == null) {
|
||||
out.add(msg);
|
||||
continue;
|
||||
}
|
||||
var history = CachedMessage.parseEditHistory(existing['edit_history']);
|
||||
final oldText = existing['text']?.toString();
|
||||
if ((oldText ?? '') != (msg.text ?? '') &&
|
||||
oldText != null &&
|
||||
oldText.isNotEmpty) {
|
||||
history = CachedMessage.appendEditHistory(history, oldText, now);
|
||||
}
|
||||
out.add(history == null ? msg : msg.copyWith(editHistory: history));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Загружает сообщения из локальной базы данных.
|
||||
|
||||
@@ -190,7 +190,7 @@ class AppDatabase {
|
||||
await _migrateLegacyDb(target);
|
||||
return openDatabase(
|
||||
target,
|
||||
version: 14,
|
||||
version: 15,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -244,6 +244,9 @@ class AppDatabase {
|
||||
db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 15) {
|
||||
await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -359,6 +362,7 @@ class AppDatabase {
|
||||
status TEXT,
|
||||
payload TEXT,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
edit_history TEXT,
|
||||
PRIMARY KEY (id, account_id),
|
||||
FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -780,6 +784,21 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadMessagesByIds(
|
||||
int accountId,
|
||||
int chatId,
|
||||
List<String> messageIds,
|
||||
) async {
|
||||
if (messageIds.isEmpty) return const [];
|
||||
final db = await _instance;
|
||||
final placeholders = List.filled(messageIds.length, '?').join(',');
|
||||
return db.query(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ? AND id IN ($placeholders)',
|
||||
whereArgs: [accountId, chatId, ...messageIds],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> loadMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../backend/modules/complaints.dart';
|
||||
import '../../../core/calls/call_controller.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
@@ -1561,6 +1562,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||||
if (idx != -1) {
|
||||
final old = _messages[idx];
|
||||
final newHistory = KometSettings.viewRedacted.value
|
||||
? CachedMessage.appendEditHistory(
|
||||
old.editHistory,
|
||||
old.text,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
)
|
||||
: old.editHistory;
|
||||
final edited = CachedMessage(
|
||||
id: old.id,
|
||||
accountId: old.accountId,
|
||||
@@ -1572,6 +1580,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
payload: old.payload,
|
||||
attachments: old.attachments,
|
||||
isControl: old.isControl,
|
||||
editHistory: newHistory,
|
||||
);
|
||||
_messages[idx] = edited;
|
||||
_bumpMessages();
|
||||
@@ -2743,6 +2752,49 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return id > 0 ? id : null;
|
||||
}
|
||||
|
||||
int _complaintTypeId(String type) {
|
||||
switch (type) {
|
||||
case 'CHANNEL':
|
||||
return 5;
|
||||
case 'CHAT':
|
||||
return 4;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<({int id, String title})>> _loadReportReasons(int typeId) async {
|
||||
final reasons = await ComplaintsModule.reasonsFor(api, typeId);
|
||||
return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList();
|
||||
}
|
||||
|
||||
Future<bool> _reportMessage(
|
||||
CachedMessage message,
|
||||
int typeId,
|
||||
int reasonId,
|
||||
) async {
|
||||
final messageIdNum = int.tryParse(message.id);
|
||||
if (messageIdNum == null) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось отправить жалобу');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final ok = await ComplaintsModule.sendComplaint(
|
||||
api,
|
||||
reasonId: reasonId,
|
||||
typeId: typeId,
|
||||
ids: [messageIdNum],
|
||||
parentId: widget.chatId,
|
||||
);
|
||||
if (!mounted) return ok;
|
||||
showCustomNotification(
|
||||
context,
|
||||
ok ? 'Жалоба отправлена' : 'Не удалось отправить жалобу',
|
||||
);
|
||||
return ok;
|
||||
}
|
||||
|
||||
CachedMessage _replaceMessage(
|
||||
int index, {
|
||||
String? id,
|
||||
@@ -2761,6 +2813,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
payload: old.payload,
|
||||
attachments: old.attachments,
|
||||
isControl: old.isControl,
|
||||
editHistory: old.editHistory,
|
||||
);
|
||||
_messages[index] = updated;
|
||||
_bumpMessages();
|
||||
@@ -3373,6 +3426,11 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
onAvatarTap: _openSenderProfile,
|
||||
);
|
||||
|
||||
final canReport = !isMe && !message.isControl;
|
||||
final reportTypeId = _complaintTypeId(
|
||||
chat?.type ?? widget.chatType,
|
||||
);
|
||||
|
||||
final pressable = _SelectableMessageRow(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
@@ -3391,6 +3449,13 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
onForward: message.isControl
|
||||
? null
|
||||
: () => _forwardMessages([message]),
|
||||
loadReportReasons: canReport
|
||||
? () => _loadReportReasons(reportTypeId)
|
||||
: null,
|
||||
onReport: canReport
|
||||
? (reasonId) =>
|
||||
_reportMessage(message, reportTypeId, reasonId)
|
||||
: null,
|
||||
child: bubble,
|
||||
);
|
||||
|
||||
@@ -5825,6 +5890,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback? onForward;
|
||||
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
||||
final Future<bool> Function(int reasonId)? onReport;
|
||||
|
||||
const _SelectableMessageRow({
|
||||
required this.child,
|
||||
@@ -5839,6 +5906,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
||||
this.onEdit,
|
||||
this.onReply,
|
||||
this.onForward,
|
||||
this.loadReportReasons,
|
||||
this.onReport,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -5882,6 +5951,9 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
||||
controller: controller,
|
||||
style: AppMessageActionsStyle.current.value,
|
||||
interaction: MessageActionsInteraction.tap,
|
||||
editHistory: widget.message.editHistory,
|
||||
loadReportReasons: widget.loadReportReasons,
|
||||
onReport: widget.onReport,
|
||||
onDelete: widget.onDelete,
|
||||
onEdit: widget.onEdit,
|
||||
onReply: widget.onReply,
|
||||
@@ -5909,6 +5981,9 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
||||
controller: controller,
|
||||
style: MessageActionsStyle.list,
|
||||
interaction: MessageActionsInteraction.click,
|
||||
editHistory: widget.message.editHistory,
|
||||
loadReportReasons: widget.loadReportReasons,
|
||||
onReport: widget.onReport,
|
||||
onDelete: widget.onDelete,
|
||||
onEdit: widget.onEdit,
|
||||
onReply: widget.onReply,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../core/config/app_message_actions_style.dart';
|
||||
import '../../core/utils/format.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import 'custom_notification.dart';
|
||||
|
||||
@@ -73,6 +74,9 @@ void showMessageActions({
|
||||
required MessageActionsController controller,
|
||||
required MessageActionsStyle style,
|
||||
required VoidCallback onDispose,
|
||||
List<Map<String, dynamic>>? editHistory,
|
||||
Future<List<({int id, String title})>> Function()? loadReportReasons,
|
||||
Future<bool> Function(int reasonId)? onReport,
|
||||
VoidCallback? onDelete,
|
||||
VoidCallback? onEdit,
|
||||
VoidCallback? onReply,
|
||||
@@ -91,6 +95,9 @@ void showMessageActions({
|
||||
controller: controller,
|
||||
style: style,
|
||||
interaction: interaction,
|
||||
editHistory: editHistory,
|
||||
loadReportReasons: loadReportReasons,
|
||||
onReport: onReport,
|
||||
onDelete: onDelete,
|
||||
onEdit: onEdit,
|
||||
onReply: onReply,
|
||||
@@ -114,6 +121,9 @@ class _MessageActionsLayer extends StatefulWidget {
|
||||
final MessageActionsStyle style;
|
||||
final MessageActionsInteraction interaction;
|
||||
final VoidCallback onDismiss;
|
||||
final List<Map<String, dynamic>>? editHistory;
|
||||
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
||||
final Future<bool> Function(int reasonId)? onReport;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onReply;
|
||||
@@ -129,6 +139,9 @@ class _MessageActionsLayer extends StatefulWidget {
|
||||
required this.style,
|
||||
required this.interaction,
|
||||
required this.onDismiss,
|
||||
this.editHistory,
|
||||
this.loadReportReasons,
|
||||
this.onReport,
|
||||
this.onDelete,
|
||||
this.onEdit,
|
||||
this.onReply,
|
||||
@@ -161,6 +174,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
||||
|
||||
int _hoveredIndex = -1;
|
||||
bool _committedFired = false;
|
||||
bool _showHistory = false;
|
||||
bool _showReport = false;
|
||||
bool _reportLoading = false;
|
||||
bool _reportSending = false;
|
||||
List<({int id, String title})>? _reasons;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -303,6 +321,15 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
||||
_Action(Symbols.reply, 'Ответить', _reply),
|
||||
if (widget.onForward != null)
|
||||
_Action(Symbols.forward, 'Переслать', _forward),
|
||||
if (widget.editHistory != null && widget.editHistory!.isNotEmpty)
|
||||
_Action(Symbols.history, 'История изменений', _showHistoryView),
|
||||
if (widget.onReport != null && widget.loadReportReasons != null)
|
||||
_Action(
|
||||
Symbols.flag,
|
||||
'Пожаловаться',
|
||||
_showReportView,
|
||||
destructive: true,
|
||||
),
|
||||
_Action(
|
||||
Symbols.delete,
|
||||
'Удалить',
|
||||
@@ -312,6 +339,47 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
||||
];
|
||||
}
|
||||
|
||||
void _showHistoryView() {
|
||||
if (!mounted) return;
|
||||
setState(() => _showHistory = true);
|
||||
}
|
||||
|
||||
Future<void> _showReportView() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_showReport = true;
|
||||
_reportLoading = _reasons == null;
|
||||
});
|
||||
if (_reasons != null) return;
|
||||
final loaded = await widget.loadReportReasons?.call();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reasons = loaded ?? const [];
|
||||
_reportLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submitReport(int reasonId) async {
|
||||
if (_reportSending) return;
|
||||
setState(() => _reportSending = true);
|
||||
final report = widget.onReport;
|
||||
final ok = report == null ? false : await report(reasonId);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
await _close();
|
||||
} else {
|
||||
setState(() => _reportSending = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _backToMenu() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_showHistory = false;
|
||||
_showReport = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onControllerUpdate() {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -435,11 +503,43 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_effectiveStyle == MessageActionsStyle.radial) ...[
|
||||
..._buildButtons(t),
|
||||
_buildLabelBanner(size, t),
|
||||
] else
|
||||
_buildListMenu(t),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: _showHistory || _showReport,
|
||||
child: AnimatedOpacity(
|
||||
opacity: (_showHistory || _showReport) ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_effectiveStyle == MessageActionsStyle.radial) ...[
|
||||
..._buildButtons(t),
|
||||
_buildLabelBanner(size, t),
|
||||
] else
|
||||
_buildListMenu(t),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !(_showHistory || _showReport),
|
||||
child: AnimatedOpacity(
|
||||
opacity: (_showHistory || _showReport) ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_showReport)
|
||||
_buildReportMenu()
|
||||
else if (_showHistory)
|
||||
_buildHistoryMenu(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -447,6 +547,216 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnchoredPanel({required String title, required Widget body}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
const menuWidth = 220.0;
|
||||
|
||||
double left;
|
||||
double top;
|
||||
if (_menuRect != Rect.zero) {
|
||||
left = _menuRect.left;
|
||||
top = _menuRect.top;
|
||||
} else {
|
||||
left = widget.isMe
|
||||
? widget.originRect.right - menuWidth
|
||||
: widget.originRect.left;
|
||||
top = _showBelow
|
||||
? widget.originRect.bottom + 10
|
||||
: widget.originRect.top - 10;
|
||||
}
|
||||
left = left.clamp(8.0, size.width - menuWidth - 8.0);
|
||||
top = top.clamp(8.0, size.height - 160.0);
|
||||
final maxHeight = math.min(size.height * 0.6, size.height - top - 8.0);
|
||||
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
width: menuWidth,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
_panelBackButton(cs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
Flexible(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panelBackButton(ColorScheme cs) => Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
Haptics.tap();
|
||||
_backToMenu();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Icon(Symbols.arrow_back, color: cs.onSurface, size: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildHistoryMenu() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final history = widget.editHistory ?? const <Map<String, dynamic>>[];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (var i = 0; i < history.length; i++) {
|
||||
if (i > 0) rows.add(_historyDivider(cs));
|
||||
rows.add(
|
||||
_historyRow(
|
||||
cs,
|
||||
history[i]['text'] as String?,
|
||||
history[i]['time'],
|
||||
current: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentTime = history.isNotEmpty ? history.last['time'] : null;
|
||||
if (rows.isNotEmpty) rows.add(_historyDivider(cs));
|
||||
rows.add(_historyRow(cs, widget.messageText, currentTime, current: true));
|
||||
|
||||
return _buildAnchoredPanel(
|
||||
title: 'История изменений',
|
||||
body: SingleChildScrollView(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportMenu() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final Widget body;
|
||||
if (_reportLoading) {
|
||||
body = const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 28),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final reasons = _reasons ?? const <({int id, String title})>[];
|
||||
if (reasons.isEmpty) {
|
||||
body = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
child: Text(
|
||||
'Не удалось загрузить причины',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final rows = <Widget>[];
|
||||
for (var i = 0; i < reasons.length; i++) {
|
||||
if (i > 0) rows.add(_historyDivider(cs));
|
||||
rows.add(_reasonRow(cs, reasons[i]));
|
||||
}
|
||||
body = SingleChildScrollView(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
|
||||
);
|
||||
}
|
||||
}
|
||||
return _buildAnchoredPanel(title: 'Пожаловаться', body: body);
|
||||
}
|
||||
|
||||
Widget _reasonRow(ColorScheme cs, ({int id, String title}) reason) =>
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _reportSending ? null : () => _submitReport(reason.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Text(
|
||||
reason.title,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _historyDivider(ColorScheme cs) => Divider(
|
||||
height: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25),
|
||||
);
|
||||
|
||||
Widget _historyRow(
|
||||
ColorScheme cs,
|
||||
String? text,
|
||||
dynamic time, {
|
||||
required bool current,
|
||||
}) {
|
||||
final ms = time is int ? time : int.tryParse(time?.toString() ?? '');
|
||||
final dateStr = ms != null
|
||||
? formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms))
|
||||
: '';
|
||||
final label = current
|
||||
? (dateStr.isEmpty ? 'текущая версия' : 'текущая версия · $dateStr')
|
||||
: dateStr;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
text == null || text.isEmpty ? '(без текста)' : text,
|
||||
style: TextStyle(
|
||||
color: current ? cs.primary : cs.onSurface,
|
||||
fontSize: 15,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListMenu(double t) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final eased = Curves.easeOutCubic.transform(t);
|
||||
|
||||
Reference in New Issue
Block a user