mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 08:52:11 +00:00
Improved: Flame restore experience
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
b7c4832ee2
commit
cd5409d021
7 changed files with 168 additions and 81 deletions
|
|
@ -5,6 +5,7 @@
|
|||
- New: Adds an "Ask a Friend" button to new contact suggestions.
|
||||
- New: Adds security profiles.
|
||||
- Improved: Onboarding flow for new users.
|
||||
- Improved: Flame restore experience.
|
||||
- Improved: The blue verification checkmark now displays the total number of verifications.
|
||||
- Fix: Issue with receiving messages when user closed app while decrypting
|
||||
- Fix: Background message fetching reliability.
|
||||
|
|
|
|||
|
|
@ -65,23 +65,18 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
|
|||
({int counter, bool isExpiring}) getFlameCounterFromGroup(Group? group) {
|
||||
const zero = (counter: 0, isExpiring: false);
|
||||
if (group == null) return zero;
|
||||
if (group.lastMessageSend == null ||
|
||||
group.lastMessageReceived == null ||
|
||||
group.lastFlameCounterChange == null) {
|
||||
if (group.lastMessageSend == null || group.lastMessageReceived == null || group.lastFlameCounterChange == null) {
|
||||
return zero;
|
||||
}
|
||||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
final oneDayAgo = startOfToday.subtract(const Duration(days: 1));
|
||||
if (group.lastMessageSend!.isAfter(twoDaysAgo) &&
|
||||
group.lastMessageReceived!.isAfter(twoDaysAgo) ||
|
||||
if (group.lastMessageSend!.isAfter(twoDaysAgo) && group.lastMessageReceived!.isAfter(twoDaysAgo) ||
|
||||
group.lastFlameCounterChange!.isAfter(oneDayAgo)) {
|
||||
// Flame is expiring when today no exchange has happened yet:
|
||||
// both lastMessageSend and lastMessageReceived are before startOfToday.
|
||||
final isExpiring =
|
||||
group.lastMessageSend!.isBefore(oneDayAgo) ||
|
||||
group.lastMessageReceived!.isBefore(oneDayAgo);
|
||||
final isExpiring = group.lastMessageSend!.isBefore(oneDayAgo) || group.lastMessageReceived!.isBefore(oneDayAgo);
|
||||
return (counter: group.flameCounter, isExpiring: isExpiring);
|
||||
} else {
|
||||
return zero;
|
||||
|
|
@ -122,8 +117,7 @@ Future<void> incFlameCounter(
|
|||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
if (group.lastMessageSend!.isBefore(twoDaysAgo) ||
|
||||
group.lastMessageReceived!.isBefore(twoDaysAgo)) {
|
||||
if (group.lastMessageSend!.isBefore(twoDaysAgo) || group.lastMessageReceived!.isBefore(twoDaysAgo)) {
|
||||
flameCounter = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,25 +129,21 @@ Future<void> incFlameCounter(
|
|||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
|
||||
if (group.lastFlameCounterChange == null ||
|
||||
group.lastFlameCounterChange!.isBefore(startOfToday)) {
|
||||
if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(startOfToday)) {
|
||||
// last flame update was yesterday. check if it can be updated.
|
||||
var updateFlame = false;
|
||||
if (received) {
|
||||
if (group.lastMessageSend != null &&
|
||||
group.lastMessageSend!.isAfter(startOfToday)) {
|
||||
if (group.lastMessageSend != null && group.lastMessageSend!.isAfter(startOfToday)) {
|
||||
// today a message was already send -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
} else if (group.lastMessageReceived != null &&
|
||||
group.lastMessageReceived!.isAfter(startOfToday)) {
|
||||
} else if (group.lastMessageReceived != null && group.lastMessageReceived!.isAfter(startOfToday)) {
|
||||
// today a message was already received -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
if (updateFlame) {
|
||||
flameCounter += 1;
|
||||
if (group.lastFlameCounterChange == null ||
|
||||
group.lastFlameCounterChange!.isBefore(timestamp)) {
|
||||
if (group.lastFlameCounterChange == null || group.lastFlameCounterChange!.isBefore(timestamp)) {
|
||||
// only update if the timestamp is newer
|
||||
lastFlameCounterChange = Value(timestamp);
|
||||
}
|
||||
|
|
@ -170,13 +160,11 @@ Future<void> incFlameCounter(
|
|||
}
|
||||
|
||||
if (received) {
|
||||
if (group.lastMessageReceived == null ||
|
||||
group.lastMessageReceived!.isBefore(timestamp)) {
|
||||
if (group.lastMessageReceived == null || group.lastMessageReceived!.isBefore(timestamp)) {
|
||||
lastMessageReceived = Value(timestamp);
|
||||
}
|
||||
} else {
|
||||
if (group.lastMessageSend == null ||
|
||||
group.lastMessageSend!.isBefore(timestamp)) {
|
||||
if (group.lastMessageSend == null || group.lastMessageSend!.isBefore(timestamp)) {
|
||||
lastMessageSend = Value(timestamp);
|
||||
}
|
||||
}
|
||||
|
|
@ -203,3 +191,18 @@ bool isItPossibleToRestoreFlames(Group group) {
|
|||
clock.now().subtract(const Duration(days: 7)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> restoreFlames(String groupId) async {
|
||||
final group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||
if (group == null) return;
|
||||
final now = clock.now();
|
||||
await twonlyDB.groupsDao.updateGroup(
|
||||
groupId,
|
||||
GroupsCompanion(
|
||||
flameCounter: Value(group.maxFlameCounter),
|
||||
lastFlameCounterChange: Value(now),
|
||||
lastMessageSend: Value(now),
|
||||
lastMessageReceived: Value(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
||||
import 'package:twonly/src/visual/elements/better_text.element.dart';
|
||||
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
|
||||
|
|
@ -42,14 +43,27 @@ class ChatFlameRestoredEntry extends StatelessWidget {
|
|||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
color: info.color,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: BetterText(
|
||||
text: context.lang.chatEntryFlameRestored(
|
||||
data.restoredFlameCounter.toInt(),
|
||||
),
|
||||
textColor: isDarkMode(context) ? Colors.black : Colors.black,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: EmojiAnimationComp(emoji: '🔥'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: BetterText(
|
||||
text: context.lang.chatEntryFlameRestored(
|
||||
data.restoredFlameCounter.toInt(),
|
||||
),
|
||||
textColor: isDarkMode(context) ? Colors.black : Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
|||
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
|
||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/user_discovery_manual_approval.comp.dart';
|
||||
import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart';
|
||||
|
||||
class MessageInput extends StatefulWidget {
|
||||
const MessageInput({
|
||||
|
|
@ -53,6 +54,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
RecordingState _recordingState = RecordingState.none;
|
||||
Timer? _nextTypingIndicator;
|
||||
DateTime? _lastTextChangeTime;
|
||||
int? _contactId;
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
if (_textFieldController.text == '') return;
|
||||
|
|
@ -83,13 +85,13 @@ class _MessageInputState extends State<MessageInput> {
|
|||
) async {
|
||||
if (widget.textFieldFocus.hasFocus &&
|
||||
_lastTextChangeTime != null &&
|
||||
DateTime.now().difference(_lastTextChangeTime!) <=
|
||||
const Duration(seconds: 6)) {
|
||||
DateTime.now().difference(_lastTextChangeTime!) <= const Duration(seconds: 6)) {
|
||||
await sendTypingIndication(widget.group.groupId, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
_initializeControllers();
|
||||
_loadContactId();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -205,11 +207,35 @@ class _MessageInputState extends State<MessageInput> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _loadContactId() async {
|
||||
if (widget.group.isDirectChat) {
|
||||
final members = await twonlyDB.groupsDao.getGroupContact(widget.group.groupId);
|
||||
if (members.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_contactId = members.first.userId;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
UserDiscoveryManualApprovalComp(group: widget.group),
|
||||
if (_contactId != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: RestoreFlameComp(
|
||||
contactId: _contactId!,
|
||||
flameOnRightSide: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 10,
|
||||
|
|
@ -252,9 +278,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
),
|
||||
child: FaIcon(
|
||||
size: 20,
|
||||
_emojiShowing
|
||||
? FontAwesomeIcons.keyboard
|
||||
: FontAwesomeIcons.faceSmile,
|
||||
_emojiShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -266,8 +290,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
controller: _textFieldController,
|
||||
focusNode: widget.textFieldFocus,
|
||||
keyboardType: TextInputType.multiline,
|
||||
showCursor:
|
||||
_recordingState != RecordingState.recording,
|
||||
showCursor: _recordingState != RecordingState.recording,
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
onChanged: (value) async {
|
||||
|
|
@ -318,9 +341,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
_currentDuration,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: isDarkMode(context)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
color: isDarkMode(context) ? Colors.white : Colors.black,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
|
|
@ -370,17 +391,13 @@ class _MessageInputState extends State<MessageInput> {
|
|||
GestureDetector(
|
||||
onLongPressMoveUpdate: (details) {
|
||||
if (_audioRecordingLock) return;
|
||||
if (_recordingOffset.dy -
|
||||
details.localPosition.dy >=
|
||||
100) {
|
||||
if (_recordingOffset.dy - details.localPosition.dy >= 100) {
|
||||
HapticFeedback.heavyImpact();
|
||||
setState(() {
|
||||
_audioRecordingLock = true;
|
||||
});
|
||||
}
|
||||
if (_recordingOffset.dx -
|
||||
details.localPosition.dx >=
|
||||
90 &&
|
||||
if (_recordingOffset.dx - details.localPosition.dx >= 90 &&
|
||||
_recordingState == RecordingState.recording) {
|
||||
_recordingState = RecordingState.none;
|
||||
HapticFeedback.heavyImpact();
|
||||
|
|
@ -388,13 +405,9 @@ class _MessageInputState extends State<MessageInput> {
|
|||
}
|
||||
|
||||
setState(() {
|
||||
final a =
|
||||
_recordingOffset.dx -
|
||||
details.localPosition.dx;
|
||||
final a = _recordingOffset.dx - details.localPosition.dx;
|
||||
if (a > 0 && a <= 90) {
|
||||
_cancelSlideOffset =
|
||||
_recordingOffset.dx -
|
||||
details.localPosition.dx;
|
||||
_cancelSlideOffset = _recordingOffset.dx - details.localPosition.dx;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -414,8 +427,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (_recordingState == RecordingState.recording &&
|
||||
!_audioRecordingLock)
|
||||
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
|
||||
Positioned.fill(
|
||||
top: -120,
|
||||
left: -5,
|
||||
|
|
@ -426,9 +438,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
color: isDarkMode(context)
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
|
|
@ -448,8 +458,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (_recordingState == RecordingState.recording &&
|
||||
!_audioRecordingLock)
|
||||
if (_recordingState == RecordingState.recording && !_audioRecordingLock)
|
||||
Positioned.fill(
|
||||
top: -20,
|
||||
left: -25,
|
||||
|
|
@ -476,15 +485,10 @@ class _MessageInputState extends State<MessageInput> {
|
|||
),
|
||||
child: FaIcon(
|
||||
size: 20,
|
||||
color:
|
||||
(_recordingState ==
|
||||
RecordingState.recording)
|
||||
? Colors.white
|
||||
: null,
|
||||
color: (_recordingState == RecordingState.recording) ? Colors.white : null,
|
||||
(_recordingState == RecordingState.none)
|
||||
? FontAwesomeIcons.microphone
|
||||
: (_recordingState ==
|
||||
RecordingState.recording)
|
||||
: (_recordingState == RecordingState.recording)
|
||||
? FontAwesomeIcons.stop
|
||||
: FontAwesomeIcons.play,
|
||||
),
|
||||
|
|
@ -504,9 +508,7 @@ class _MessageInputState extends State<MessageInput> {
|
|||
color: context.color.primary,
|
||||
FontAwesomeIcons.solidPaperPlane,
|
||||
),
|
||||
onPressed: _audioRecordingLock
|
||||
? _stopAudioRecording
|
||||
: _sendMessage,
|
||||
onPressed: _audioRecordingLock ? _stopAudioRecording : _sendMessage,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
|
@ -11,8 +10,7 @@ import 'package:twonly/src/constants/routes.keys.dart';
|
|||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
|
||||
as pb;
|
||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' as pb;
|
||||
import 'package:twonly/src/services/api/messages.api.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
import 'package:twonly/src/services/subscription.service.dart';
|
||||
|
|
@ -24,9 +22,11 @@ import 'package:twonly/src/visual/elements/better_list_title.element.dart';
|
|||
class RestoreFlameComp extends StatefulWidget {
|
||||
const RestoreFlameComp({
|
||||
required this.contactId,
|
||||
this.flameOnRightSide = false,
|
||||
super.key,
|
||||
});
|
||||
final int contactId;
|
||||
final bool flameOnRightSide;
|
||||
|
||||
@override
|
||||
State<RestoreFlameComp> createState() => _RestoreFlameCompState();
|
||||
|
|
@ -60,21 +60,15 @@ class _RestoreFlameCompState extends State<RestoreFlameComp> {
|
|||
final currentPlan = planFromString(
|
||||
userService.currentUser.subscriptionPlan,
|
||||
);
|
||||
if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) &&
|
||||
kReleaseMode) {
|
||||
if (!isUserAllowed(currentPlan, PremiumFeatures.RestoreFlames) && kReleaseMode) {
|
||||
await context.push(Routes.settingsSubscription);
|
||||
return;
|
||||
}
|
||||
Log.info(
|
||||
'Restoring flames from ${_group!.flameCounter} to ${_group!.maxFlameCounter}',
|
||||
);
|
||||
await twonlyDB.groupsDao.updateGroup(
|
||||
_groupId,
|
||||
GroupsCompanion(
|
||||
flameCounter: Value(_group!.maxFlameCounter),
|
||||
lastFlameCounterChange: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
|
||||
await restoreFlames(_groupId);
|
||||
|
||||
final addData = AdditionalMessageData(
|
||||
type: AdditionalMessageData_Type.RESTORED_FLAME_COUNTER,
|
||||
|
|
@ -116,6 +110,22 @@ class _RestoreFlameCompState extends State<RestoreFlameComp> {
|
|||
if (_group == null || !isItPossibleToRestoreFlames(_group!)) {
|
||||
return Container();
|
||||
}
|
||||
if (widget.flameOnRightSide) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
onTap: _restoreFlames,
|
||||
title: Text(
|
||||
'Restore your ${_group!.maxFlameCounter} lost flames',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
trailing: const SizedBox(
|
||||
width: 24,
|
||||
child: EmojiAnimationComp(
|
||||
emoji: '🔥',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return BetterListTile(
|
||||
onTap: _restoreFlames,
|
||||
leading: const SizedBox(
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('test flame counter', () async {
|
||||
test('normal flame expiring', () async {
|
||||
final contactId = await getAndCreateUserId();
|
||||
final contact = (await twonlyDB.contactsDao.getContactById(contactId))!;
|
||||
await twonlyDB.groupsDao.createNewDirectChat(
|
||||
|
|
@ -182,6 +182,21 @@ void main() {
|
|||
counter: 0,
|
||||
isExpiring: false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isRestore Possible', () async {
|
||||
final contactId = await getAndCreateUserId();
|
||||
final contact = (await twonlyDB.contactsDao.getContactById(contactId))!;
|
||||
await twonlyDB.groupsDao.createNewDirectChat(
|
||||
contactId,
|
||||
GroupsCompanion(
|
||||
groupName: Value(
|
||||
getContactDisplayName(contact),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final group = (await twonlyDB.groupsDao.getDirectChat(contactId))!;
|
||||
|
||||
for (var i = 1; i <= 20; i++) {
|
||||
await withClock(
|
||||
|
|
@ -226,6 +241,48 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('flame restoring', () async {
|
||||
final contactId = await getAndCreateUserId();
|
||||
final contact = (await twonlyDB.contactsDao.getContactById(contactId))!;
|
||||
await twonlyDB.groupsDao.createNewDirectChat(
|
||||
contactId,
|
||||
GroupsCompanion(
|
||||
groupName: Value(
|
||||
getContactDisplayName(contact),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final group = (await twonlyDB.groupsDao.getDirectChat(contactId))!;
|
||||
|
||||
for (var i = 1; i <= 5; i++) {
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 3, i, 1)),
|
||||
() async {
|
||||
await incFlameCounter(group.groupId, true, DateTime(2026, 3, i, 2));
|
||||
await incFlameCounter(group.groupId, false, DateTime(2026, 3, i, 3));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await expectFlame(DateTime(2026, 3, 5, 19), group.groupId, 5);
|
||||
await expectFlame(DateTime(2026, 3, 8, 19), group.groupId, 0);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 3, 9, 12)),
|
||||
() async {
|
||||
await restoreFlames(group.groupId);
|
||||
},
|
||||
);
|
||||
|
||||
await expectFlameExpiring(
|
||||
DateTime(2026, 3, 9, 13),
|
||||
group.groupId,
|
||||
counter: 5,
|
||||
isExpiring: false,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await twonlyDB.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// ignore_for_file: avoid_print, avoid_dynamic_calls
|
||||
// ignore_for_file: avoid_dynamic_calls
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
|
|||
Loading…
Reference in a new issue