♻️ 使用 SQLITE 来存储本地消息记录 #1

Merged
LittleSheep merged 6 commits from features/local-message-history into master 2024-06-23 11:13:42 +00:00
14 changed files with 262 additions and 221 deletions
Showing only changes of commit aa8eec1a5a - Show all commits

View File

@ -0,0 +1,48 @@
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/providers/message/helper.dart';
import 'package:solian/providers/message/history.dart';
class ChatHistoryController {
late final MessageHistoryDb database;
final RxList<LocalMessage> currentHistory = RxList.empty(growable: true);
final RxInt totalHistoryCount = 0.obs;
initialize() async {
database = await createHistoryDb();
currentHistory.clear();
}
Future<void> getMessages(Channel channel, String scope) async {
totalHistoryCount.value = await database.syncMessages(channel, scope: scope);
await syncHistory(channel);
}
Future<void> syncHistory(Channel channel) async {
currentHistory.replaceRange(0, currentHistory.length,
await database.localMessages.findAllByChannel(channel.id));
}
receiveMessage(Message remote) async {
final entry = await database.receiveMessage(remote);
totalHistoryCount.value++;
currentHistory.add(entry);
}
void replaceMessage(Message remote) async {
final entry = await database.replaceMessage(remote);
currentHistory.replaceRange(
0,
currentHistory.length,
currentHistory.map((x) => x.id == entry.id ? entry : x),
);
}
void burnMessage(int id) async {
await database.burnMessage(id);
totalHistoryCount.value--;
currentHistory.removeWhere((x) => x.id == id);
}
}

View File

@ -81,24 +81,24 @@ class AccountBadge {
}); });
factory AccountBadge.fromJson(Map<String, dynamic> json) => AccountBadge( factory AccountBadge.fromJson(Map<String, dynamic> json) => AccountBadge(
id: json["id"], id: json['id'],
accountId: json["account_id"], accountId: json['account_id'],
updatedAt: DateTime.parse(json["updated_at"]), updatedAt: DateTime.parse(json['updated_at']),
createdAt: DateTime.parse(json["created_at"]), createdAt: DateTime.parse(json['created_at']),
deletedAt: json["deleted_at"] != null deletedAt: json['deleted_at'] != null
? DateTime.parse(json["deleted_at"]) ? DateTime.parse(json['deleted_at'])
: null, : null,
metadata: json["metadata"], metadata: json['metadata'],
type: json["type"], type: json['type'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"id": id, 'id': id,
"account_id": accountId, 'account_id': accountId,
"created_at": createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
"deleted_at": deletedAt?.toIso8601String(), 'deleted_at': deletedAt?.toIso8601String(),
"metadata": metadata, 'metadata': metadata,
"type": type, 'type': type,
}; };
} }

View File

@ -38,40 +38,40 @@ class Attachment {
}); });
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment( factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json["id"], id: json['id'],
createdAt: DateTime.parse(json["created_at"]), createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json["updated_at"]), updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json["deleted_at"], deletedAt: json['deleted_at'],
uuid: json["uuid"], uuid: json['uuid'],
size: json["size"], size: json['size'],
name: json["name"], name: json['name'],
alt: json["alt"], alt: json['alt'],
usage: json["usage"], usage: json['usage'],
mimetype: json["mimetype"], mimetype: json['mimetype'],
hash: json["hash"], hash: json['hash'],
destination: json["destination"], destination: json['destination'],
metadata: json["metadata"], metadata: json['metadata'],
isMature: json["is_mature"], isMature: json['is_mature'],
account: Account.fromJson(json["account"]), account: Account.fromJson(json['account']),
accountId: json["account_id"], accountId: json['account_id'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"id": id, 'id': id,
"created_at": createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
"deleted_at": deletedAt, 'deleted_at': deletedAt,
"uuid": uuid, 'uuid': uuid,
"size": size, 'size': size,
"name": name, 'name': name,
"alt": alt, 'alt': alt,
"usage": usage, 'usage': usage,
"mimetype": mimetype, 'mimetype': mimetype,
"hash": hash, 'hash': hash,
"destination": destination, 'destination': destination,
"metadata": metadata, 'metadata': metadata,
"is_mature": isMature, 'is_mature': isMature,
"account": account.toJson(), 'account': account.toJson(),
"account_id": accountId, 'account_id': accountId,
}; };
} }

View File

@ -44,8 +44,8 @@ class Message {
deletedAt: json['deleted_at'], deletedAt: json['deleted_at'],
content: json['content'], content: json['content'],
type: json['type'], type: json['type'],
attachments: json["attachments"] != null attachments: json['attachments'] != null
? List<int>.from(json["attachments"]) ? List<int>.from(json['attachments'])
: null, : null,
channel: channel:
json['channel'] != null ? Channel.fromJson(json['channel']) : null, json['channel'] != null ? Channel.fromJson(json['channel']) : null,

View File

@ -53,36 +53,36 @@ class Post {
}); });
factory Post.fromJson(Map<String, dynamic> json) => Post( factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json["id"], id: json['id'],
createdAt: DateTime.parse(json["created_at"]), createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json["updated_at"]), updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json["deleted_at"] != null deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at']) ? DateTime.parse(json['deleted_at'])
: null, : null,
alias: json["alias"], alias: json['alias'],
content: json["content"], content: json['content'],
tags: json["tags"], tags: json['tags'],
categories: json["categories"], categories: json['categories'],
reactions: json["reactions"], reactions: json['reactions'],
replies: json["replies"], replies: json['replies'],
attachments: json["attachments"] != null attachments: json['attachments'] != null
? List<int>.from(json["attachments"]) ? List<int>.from(json['attachments'])
: null, : null,
replyId: json["reply_id"], replyId: json['reply_id'],
repostId: json["repost_id"], repostId: json['repost_id'],
realmId: json["realm_id"], realmId: json['realm_id'],
replyTo: replyTo:
json["reply_to"] != null ? Post.fromJson(json["reply_to"]) : null, json['reply_to'] != null ? Post.fromJson(json['reply_to']) : null,
repostTo: repostTo:
json["repost_to"] != null ? Post.fromJson(json["repost_to"]) : null, json['repost_to'] != null ? Post.fromJson(json['repost_to']) : null,
realm: json["realm"] != null ? Realm.fromJson(json["realm"]) : null, realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
publishedAt: json["published_at"] != null ? DateTime.parse(json["published_at"]) : null, publishedAt: json['published_at'] != null ? DateTime.parse(json['published_at']) : null,
authorId: json["author_id"], authorId: json['author_id'],
author: Account.fromJson(json["author"]), author: Account.fromJson(json['author']),
replyCount: json["reply_count"], replyCount: json['reply_count'],
reactionCount: json["reaction_count"], reactionCount: json['reaction_count'],
reactionList: json["reaction_list"] != null reactionList: json['reaction_list'] != null
? json["reaction_list"] ? json['reaction_list']
.map((key, value) => MapEntry( .map((key, value) => MapEntry(
key, key,
int.tryParse(value.toString()) ?? int.tryParse(value.toString()) ??
@ -92,28 +92,28 @@ class Post {
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"id": id, 'id': id,
"created_at": createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
"deleted_at": deletedAt, 'deleted_at': deletedAt,
"alias": alias, 'alias': alias,
"content": content, 'content': content,
"tags": tags, 'tags': tags,
"categories": categories, 'categories': categories,
"reactions": reactions, 'reactions': reactions,
"replies": replies, 'replies': replies,
"attachments": attachments, 'attachments': attachments,
"reply_id": replyId, 'reply_id': replyId,
"repost_id": repostId, 'repost_id': repostId,
"realm_id": realmId, 'realm_id': realmId,
"reply_to": replyTo?.toJson(), 'reply_to': replyTo?.toJson(),
"repost_to": repostTo?.toJson(), 'repost_to': repostTo?.toJson(),
"realm": realm?.toJson(), 'realm': realm?.toJson(),
"published_at": publishedAt?.toIso8601String(), 'published_at': publishedAt?.toIso8601String(),
"author_id": authorId, 'author_id': authorId,
"author": author.toJson(), 'author': author.toJson(),
"reply_count": replyCount, 'reply_count': replyCount,
"reaction_count": reactionCount, 'reaction_count': reactionCount,
"reaction_list": reactionList, 'reaction_list': reactionList,
}; };
} }

View File

@ -190,10 +190,10 @@ class AccountProvider extends GetxController {
} }
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) { if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
provider = "apple"; provider = 'apple';
token = await FirebaseMessaging.instance.getAPNSToken(); token = await FirebaseMessaging.instance.getAPNSToken();
} else { } else {
provider = "firebase"; provider = 'firebase';
token = await FirebaseMessaging.instance.getToken(); token = await FirebaseMessaging.instance.getToken();
} }

View File

@ -79,7 +79,7 @@ class AuthProvider extends GetConnect {
if (credentials!.isExpired) { if (credentials!.isExpired) {
await refreshCredentials(); await refreshCredentials();
log("Refreshed credentials at ${DateTime.now()}"); log('Refreshed credentials at ${DateTime.now()}');
} }
} }

View File

@ -13,19 +13,23 @@ Future<MessageHistoryDb> createHistoryDb() async {
extension MessageHistoryHelper on MessageHistoryDb { extension MessageHistoryHelper on MessageHistoryDb {
receiveMessage(Message remote) async { receiveMessage(Message remote) async {
await localMessages.insert(LocalMessage( final entry = LocalMessage(
remote.id, remote.id,
remote, remote,
remote.channelId, remote.channelId,
)); );
await localMessages.insert(entry);
return entry;
} }
replaceMessage(Message remote) async { replaceMessage(Message remote) async {
await localMessages.update(LocalMessage( final entry = LocalMessage(
remote.id, remote.id,
remote, remote,
remote.channelId, remote.channelId,
)); );
await localMessages.update(entry);
return entry;
} }
burnMessage(int id) async { burnMessage(int id) async {
@ -38,18 +42,22 @@ extension MessageHistoryHelper on MessageHistoryDb {
final data = await _getRemoteMessages( final data = await _getRemoteMessages(
channel, channel,
scope, scope,
remainBreath: 3, remainBreath: 10,
offset: offset, offset: offset,
onBrake: (items) { onBrake: (items) {
return items.any((x) => x.id == lastOne?.id); return items.any((x) => x.id == lastOne?.id);
}, },
); );
await localMessages.insertBulk( if (data != null) {
data.map((x) => LocalMessage(x.id, x, x.channelId)).toList(), await localMessages.insertBulk(
); data.$1.map((x) => LocalMessage(x.id, x, x.channelId)).toList(),
);
}
return data?.$2 ?? 0;
} }
Future<List<Message>> _getRemoteMessages( Future<(List<Message>, int)?> _getRemoteMessages(
Channel channel, Channel channel,
String scope, { String scope, {
required int remainBreath, required int remainBreath,
@ -58,11 +66,11 @@ extension MessageHistoryHelper on MessageHistoryDb {
offset = 0, offset = 0,
}) async { }) async {
if (remainBreath <= 0) { if (remainBreath <= 0) {
return List.empty(); return null;
} }
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return List.empty(); if (!await auth.isAuthorized) return null;
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
@ -78,18 +86,20 @@ extension MessageHistoryHelper on MessageHistoryDb {
response.data?.map((e) => Message.fromJson(e)).toList() ?? List.empty(); response.data?.map((e) => Message.fromJson(e)).toList() ?? List.empty();
if (onBrake != null && onBrake(result)) { if (onBrake != null && onBrake(result)) {
return result; return (result, response.count);
} }
final expandResult = await _getRemoteMessages( final expandResult = (await _getRemoteMessages(
channel, channel,
scope, scope,
remainBreath: remainBreath - 1, remainBreath: remainBreath - 1,
take: take, take: take,
offset: offset + result.length, offset: offset + result.length,
); ))
?.$1 ??
List.empty();
return [...result, ...expandResult]; return ([...result, ...expandResult], response.count);
} }
Future<List<LocalMessage>> listMessages(Channel channel) async { Future<List<LocalMessage>> listMessages(Channel channel) async {

View File

@ -27,9 +27,9 @@ class _CallScreenState extends State<CallScreen> {
DateTime.now().difference(provider.current.value!.createdAt); DateTime.now().difference(provider.current.value!.createdAt);
String twoDigits(int n) => n.toString().padLeft(2, '0'); String twoDigits(int n) => n.toString().padLeft(2, '0');
String formattedTime = "${twoDigits(duration.inHours)}:" String formattedTime = '${twoDigits(duration.inHours)}:'
"${twoDigits(duration.inMinutes.remainder(60))}:" '${twoDigits(duration.inMinutes.remainder(60))}:'
"${twoDigits(duration.inSeconds.remainder(60))}"; '${twoDigits(duration.inSeconds.remainder(60))}';
return formattedTime; return formattedTime;
} }
@ -66,7 +66,7 @@ class _CallScreenState extends State<CallScreen> {
text: 'call'.tr, text: 'call'.tr,
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const TextSpan(text: "\n"), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: currentDuration, text: currentDuration,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_history_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -13,8 +13,6 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/providers/content/call.dart'; import 'package:solian/providers/content/call.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/message/helper.dart';
import 'package:solian/providers/message/history.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
@ -41,10 +39,7 @@ class ChannelChatScreen extends StatefulWidget {
} }
class _ChannelChatScreenState extends State<ChannelChatScreen> { class _ChannelChatScreenState extends State<ChannelChatScreen> {
final _chatScrollController = ScrollController();
bool _isBusy = false; bool _isBusy = false;
bool _isLoadingMore = false;
int? _accountId; int? _accountId;
String? _overrideAlias; String? _overrideAlias;
@ -54,9 +49,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
ChannelMember? _channelProfile; ChannelMember? _channelProfile;
StreamSubscription<NetworkPackage>? _subscription; StreamSubscription<NetworkPackage>? _subscription;
int _nextHistorySyncOffset = 0; late final ChatHistoryController _chatController;
MessageHistoryDb? _db;
List<LocalMessage> _currentHistory = List.empty();
getProfile() async { getProfile() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@ -111,22 +104,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
Future<void> getMessages() async {
await _db!.syncMessages(
_channel!,
scope: widget.realm,
offset: _nextHistorySyncOffset,
);
await syncHistory();
}
Future<void> syncHistory() async {
final data = await _db!.localMessages.findAllByChannel(_channel!.id);
setState(() {
_currentHistory = data;
});
}
void listenMessages() { void listenMessages() {
final ChatProvider provider = Get.find(); final ChatProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = provider.stream.stream.listen((event) {
@ -134,19 +111,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
case 'messages.new': case 'messages.new':
final payload = Message.fromJson(event.payload!); final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { if (payload.channelId == _channel?.id) {
_db?.receiveMessage(payload); _chatController.receiveMessage(payload);
} }
break; break;
case 'messages.update': case 'messages.update':
final payload = Message.fromJson(event.payload!); final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { if (payload.channelId == _channel?.id) {
_db?.replaceMessage(payload); _chatController.replaceMessage(payload);
} }
break; break;
case 'messages.burnt': case 'messages.burnt':
final payload = Message.fromJson(event.payload!); final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { if (payload.channelId == _channel?.id) {
_db?.burnMessage(payload.id); _chatController.burnMessage(payload.id);
} }
break; break;
case 'calls.new': case 'calls.new':
@ -157,7 +134,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_ongoingCall = null; _ongoingCall = null;
break; break;
} }
syncHistory();
}); });
} }
@ -211,18 +187,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool isMerged = false, hasMerged = false; bool isMerged = false, hasMerged = false;
if (index > 0) { if (index > 0) {
hasMerged = checkMessageMergeable( hasMerged = checkMessageMergeable(
_currentHistory[index - 1].data, _chatController.currentHistory[index - 1].data,
_currentHistory[index].data, _chatController.currentHistory[index].data,
); );
} }
if (index + 1 < _currentHistory.length) { if (index + 1 < _chatController.currentHistory.length) {
isMerged = checkMessageMergeable( isMerged = checkMessageMergeable(
_currentHistory[index].data, _chatController.currentHistory[index].data,
_currentHistory[index + 1].data, _chatController.currentHistory[index + 1].data,
); );
} }
final item = _currentHistory[index].data; final item = _chatController.currentHistory[index].data;
return InkWell( return InkWell(
child: Container( child: Container(
@ -253,28 +229,17 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void initState() { void initState() {
_chatScrollController.addListener(() async { _chatController = ChatHistoryController();
if (_chatScrollController.position.pixels == _chatController.initialize();
_chatScrollController.position.maxScrollExtent) {
setState(() => _isLoadingMore = true); getChannel().then((_) {
_nextHistorySyncOffset = _currentHistory.length; _chatController.getMessages(_channel!, widget.realm);
await getMessages();
setState(() => _isLoadingMore = false);
}
}); });
createHistoryDb().then((db) async { getProfile();
_db = db; getOngoingCall();
await getChannel(); listenMessages();
await syncHistory();
getProfile();
getOngoingCall();
getMessages();
listenMessages();
});
super.initState(); super.initState();
} }
@ -352,52 +317,66 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Column( Column(
children: [ children: [
if (_isLoadingMore)
const LinearProgressIndicator()
.paddingOnly(bottom: 4)
.animate()
.slideY(),
Expanded( Expanded(
child: ListView.builder( child: CustomScrollView(
controller: _chatScrollController,
itemCount: _currentHistory.length,
clipBehavior: Clip.none,
reverse: true, reverse: true,
itemBuilder: buildHistory, slivers: [
).paddingOnly(bottom: 56), Obx(() {
return SliverList.builder(
itemCount: _chatController.currentHistory.length,
itemBuilder: buildHistory,
);
}),
Obx(() {
final amount = _chatController.totalHistoryCount -
_chatController.currentHistory.length;
if (amount > 0) {
return SliverToBoxAdapter(
child: ListTile(
tileColor: Theme.of(context)
.colorScheme
.surfaceContainerLow,
leading: const Icon(Icons.sync_disabled),
title: Text('messageUnsync'.tr),
subtitle: Text('messageUnsyncCaption'.trParams({
'count': amount.string,
})),
onTap: () {},
),
);
} else {
return const SliverToBoxAdapter(child: SizedBox());
}
}),
],
),
), ),
], ClipRect(
), child: BackdropFilter(
Positioned( filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
bottom: 0, child: SafeArea(
left: 0, child: ChatMessageInput(
right: 0, edit: _messageToEditing,
child: ClipRect( reply: _messageToReplying,
child: BackdropFilter( realm: widget.realm,
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), placeholder: placeholder,
child: SafeArea( channel: _channel!,
child: ChatMessageInput( onSent: (Message item) {
edit: _messageToEditing, setState(() {
reply: _messageToReplying, _chatController.receiveMessage(item);
realm: widget.realm, });
placeholder: placeholder, },
channel: _channel!, onReset: () {
onSent: (Message item) { setState(() {
setState(() { _messageToReplying = null;
_db?.receiveMessage(item); _messageToEditing = null;
syncHistory(); });
}); },
}, ),
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
), ),
), ),
), ),
), ],
), ),
if (_ongoingCall != null) if (_ongoingCall != null)
Positioned( Positioned(

View File

@ -164,6 +164,8 @@ class SolianMessages extends Translations {
'channelNotifyLevelMentioned': 'Only mentioned', 'channelNotifyLevelMentioned': 'Only mentioned',
'channelNotifyLevelNone': 'Ignore all', 'channelNotifyLevelNone': 'Ignore all',
'channelNotifyLevelApplied': 'Your notification settings has been applied.', 'channelNotifyLevelApplied': 'Your notification settings has been applied.',
'messageUnsync': 'Messages Un-synced',
'messageUnsyncCaption': '@count message(s) still in un-synced.',
'messageDecoding': 'Decoding...', 'messageDecoding': 'Decoding...',
'messageDecodeFailed': 'Unable to decode: @message', 'messageDecodeFailed': 'Unable to decode: @message',
'messageInputPlaceholder': 'Message @channel', 'messageInputPlaceholder': 'Message @channel',
@ -364,6 +366,8 @@ class SolianMessages extends Translations {
'channelNotifyLevelMentioned': '仅提及', 'channelNotifyLevelMentioned': '仅提及',
'channelNotifyLevelNone': '忽略一切', 'channelNotifyLevelNone': '忽略一切',
'channelNotifyLevelApplied': '你的通知设置已经应用。', 'channelNotifyLevelApplied': '你的通知设置已经应用。',
'messageUnsync': '消息未同步',
'messageUnsyncCaption': '还有 @count 条消息未同步',
'messageDecoding': '解码信息中…', 'messageDecoding': '解码信息中…',
'messageDecodeFailed': '解码信息失败:@message', 'messageDecodeFailed': '解码信息失败:@message',
'messageInputPlaceholder': '在 @channel 发信息', 'messageInputPlaceholder': '在 @channel 发信息',

View File

@ -67,7 +67,7 @@ class SliverFriendList extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverList.builder( return SliverList.builder(
itemCount: items.length, itemCount: items.length,
itemBuilder: (_, __) => buildItem(_, __), itemBuilder: (context, idx) => buildItem(context, idx),
); );
} }
} }

View File

@ -65,7 +65,7 @@ class _ChatCallButtonState extends State<ChatCallButton> {
? widget.realm?.alias ? widget.realm?.alias
: 'global'; : 'global';
final resp = await client final resp = await client
.delete('/api/channels/${scope}/${widget.channel.alias}/calls/ongoing'); .delete('/api/channels/$scope/${widget.channel.alias}/calls/ongoing');
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
if (widget.onEnded != null) widget.onEnded!(); if (widget.onEnded != null) widget.onEnded!();
} else { } else {

View File

@ -98,7 +98,7 @@ class _PostItemState extends State<PostItem> {
if (labels.isNotEmpty) { if (labels.isNotEmpty) {
return Text( return Text(
labels.join(" · "), labels.join(' · '),
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,