mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 09:28:41 +00:00
505 lines
17 KiB
Dart
505 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cryptography_plus/cryptography_plus.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
|
import 'package:twonly/src/database/tables/messages_table.dart';
|
|
import 'package:twonly/src/database/twonly_database.dart';
|
|
import 'package:twonly/src/model/json/userdata.dart';
|
|
import 'package:twonly/src/providers/connection.provider.dart';
|
|
import 'package:twonly/src/services/api/media_download.dart';
|
|
import 'package:twonly/src/utils/misc.dart';
|
|
import 'package:twonly/src/utils/storage.dart';
|
|
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
|
|
import 'package:twonly/src/views/chats/add_new_user.view.dart';
|
|
import 'package:twonly/src/views/chats/chat_list_components/backup_notice.card.dart';
|
|
import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart';
|
|
import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart';
|
|
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages.view.dart';
|
|
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
|
|
import 'package:twonly/src/views/chats/media_viewer.view.dart';
|
|
import 'package:twonly/src/views/chats/start_new_chat.view.dart';
|
|
import 'package:twonly/src/views/components/flame.dart';
|
|
import 'package:twonly/src/views/components/initialsavatar.dart';
|
|
import 'package:twonly/src/views/components/notification_badge.dart';
|
|
import 'package:twonly/src/views/components/user_context_menu.dart';
|
|
import 'package:twonly/src/views/settings/help/changelog.view.dart';
|
|
import 'package:twonly/src/views/settings/profile/profile.view.dart';
|
|
import 'package:twonly/src/views/settings/settings_main.view.dart';
|
|
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
|
|
import 'package:twonly/src/views/tutorial/tutorials.dart';
|
|
|
|
class ChatListView extends StatefulWidget {
|
|
const ChatListView({super.key});
|
|
@override
|
|
State<ChatListView> createState() => _ChatListViewState();
|
|
}
|
|
|
|
class _ChatListViewState extends State<ChatListView> {
|
|
late StreamSubscription<List<Contact>> _contactsSub;
|
|
List<Contact> _contacts = [];
|
|
List<Contact> _pinnedContacts = [];
|
|
UserData? _user;
|
|
|
|
GlobalKey firstUserListItemKey = GlobalKey();
|
|
GlobalKey searchForOtherUsers = GlobalKey();
|
|
Timer? tutorial;
|
|
bool showFeedbackShortcut = false;
|
|
|
|
@override
|
|
void initState() {
|
|
initAsync();
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> initAsync() async {
|
|
final stream = twonlyDB.contactsDao.watchContactsForChatList();
|
|
_contactsSub = stream.listen((contacts) {
|
|
setState(() {
|
|
_contacts = contacts.where((x) => !x.pinned).toList();
|
|
_pinnedContacts = contacts.where((x) => x.pinned).toList();
|
|
});
|
|
});
|
|
|
|
tutorial = Timer(const Duration(seconds: 1), () async {
|
|
tutorial = null;
|
|
if (!mounted) return;
|
|
await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers);
|
|
if (!mounted) return;
|
|
if (_contacts.isNotEmpty) {
|
|
await showChatListTutorialContextMenu(context, firstUserListItemKey);
|
|
}
|
|
});
|
|
|
|
final user = await getUser();
|
|
if (user == null) return;
|
|
_user = user;
|
|
final changeLog = await rootBundle.loadString('CHANGELOG.md');
|
|
final changeLogHash =
|
|
(await compute(Sha256().hash, changeLog.codeUnits)).bytes;
|
|
if (!user.hideChangeLog &&
|
|
user.lastChangeLogHash.toString() != changeLogHash.toString()) {
|
|
await updateUserdata((u) {
|
|
u.lastChangeLogHash = changeLogHash;
|
|
return u;
|
|
});
|
|
if (!mounted) return;
|
|
// only show changelog to people who already have contacts
|
|
// this prevents that this is shown directly after the user registered
|
|
if (_contacts.isNotEmpty) {
|
|
await Navigator.push(context, MaterialPageRoute(builder: (context) {
|
|
return ChangeLogView(
|
|
changeLog: changeLog,
|
|
);
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
tutorial?.cancel();
|
|
_contactsSub.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isConnected = context.watch<CustomChangeProvider>().isConnected;
|
|
final planId = context.watch<CustomChangeProvider>().plan;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Row(children: [
|
|
GestureDetector(
|
|
onTap: () async {
|
|
await Navigator.push(context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return const ProfileView();
|
|
}));
|
|
_user = await getUser();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
},
|
|
child: ContactAvatar(
|
|
userData: _user,
|
|
fontSize: 14,
|
|
color: context.color.onSurface.withAlpha(20),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
const Text('twonly '),
|
|
if (planId != 'Free')
|
|
GestureDetector(
|
|
onTap: () {
|
|
Navigator.push(context, MaterialPageRoute(builder: (context) {
|
|
return const SubscriptionView();
|
|
}));
|
|
},
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: context.color.primary,
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
|
child: Text(
|
|
planId,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDarkMode(context) ? Colors.black : Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
actions: [
|
|
const FeedbackIconButton(),
|
|
StreamBuilder(
|
|
stream: twonlyDB.contactsDao.watchContactsRequested(),
|
|
builder: (context, snapshot) {
|
|
var count = 0;
|
|
if (snapshot.hasData && snapshot.data != null) {
|
|
count = snapshot.data!;
|
|
}
|
|
return NotificationBadge(
|
|
count: count.toString(),
|
|
child: IconButton(
|
|
key: searchForOtherUsers,
|
|
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const AddNewUserView(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
onPressed: () async {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const SettingsMainView(),
|
|
),
|
|
);
|
|
_user = await getUser();
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
},
|
|
icon: const FaIcon(FontAwesomeIcons.gear, size: 19),
|
|
)
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: isConnected ? Container() : const ConnectionInfo(),
|
|
),
|
|
Positioned.fill(
|
|
child: (_contacts.isEmpty && _pinnedContacts.isEmpty)
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.person_add),
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const AddNewUserView(),
|
|
),
|
|
);
|
|
},
|
|
label:
|
|
Text(context.lang.chatListViewSearchUserNameBtn)),
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await apiService.close(() {});
|
|
await apiService.connect(force: true);
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
},
|
|
child: ListView.builder(
|
|
itemCount: _pinnedContacts.length +
|
|
(_pinnedContacts.isNotEmpty ? 1 : 0) +
|
|
_contacts.length +
|
|
1,
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return const BackupNoticeCard();
|
|
}
|
|
index -= 1;
|
|
// Check if the index is for the pinned users
|
|
if (index < _pinnedContacts.length) {
|
|
final contact = _pinnedContacts[index];
|
|
return UserListItem(
|
|
key: ValueKey(contact.userId),
|
|
user: contact,
|
|
firstUserListItemKey: (index == 0 || index == 1)
|
|
? firstUserListItemKey
|
|
: null,
|
|
);
|
|
}
|
|
|
|
// If there are pinned users, account for the Divider
|
|
var adjustedIndex = index - _pinnedContacts.length;
|
|
if (_pinnedContacts.isNotEmpty && adjustedIndex == 0) {
|
|
return const Divider();
|
|
}
|
|
|
|
// Adjust the index for the contacts list
|
|
adjustedIndex -= (_pinnedContacts.isNotEmpty ? 1 : 0);
|
|
|
|
// Get the contacts that are not pinned
|
|
final contact = _contacts.elementAt(
|
|
adjustedIndex,
|
|
);
|
|
return UserListItem(
|
|
key: ValueKey(contact.userId),
|
|
user: contact,
|
|
firstUserListItemKey:
|
|
(index == 0) ? firstUserListItemKey : null,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: Padding(
|
|
padding: const EdgeInsets.only(bottom: 30),
|
|
child: FloatingActionButton(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return const StartNewChatView();
|
|
}),
|
|
);
|
|
},
|
|
child: const FaIcon(FontAwesomeIcons.penToSquare),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserListItem extends StatefulWidget {
|
|
const UserListItem({
|
|
required this.user,
|
|
required this.firstUserListItemKey,
|
|
super.key,
|
|
});
|
|
final Contact user;
|
|
final GlobalKey? firstUserListItemKey;
|
|
|
|
@override
|
|
State<UserListItem> createState() => _UserListItem();
|
|
}
|
|
|
|
class _UserListItem extends State<UserListItem> {
|
|
MessageSendState state = MessageSendState.send;
|
|
Message? currentMessage;
|
|
|
|
List<Message> messagesNotOpened = [];
|
|
late StreamSubscription<List<Message>> messagesNotOpenedStream;
|
|
|
|
List<Message> lastMessages = [];
|
|
late StreamSubscription<List<Message>> lastMessageStream;
|
|
|
|
List<Message> previewMessages = [];
|
|
bool hasNonOpenedMediaFile = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initStreams();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
messagesNotOpenedStream.cancel();
|
|
lastMessageStream.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void initStreams() {
|
|
lastMessageStream = twonlyDB.messagesDao
|
|
.watchLastMessage(widget.user.userId)
|
|
.listen((update) {
|
|
updateState(update, messagesNotOpened);
|
|
});
|
|
|
|
messagesNotOpenedStream = twonlyDB.messagesDao
|
|
.watchMessageNotOpened(widget.user.userId)
|
|
.listen((update) {
|
|
updateState(lastMessages, update);
|
|
});
|
|
}
|
|
|
|
void updateState(
|
|
List<Message> newLastMessages,
|
|
List<Message> newMessagesNotOpened,
|
|
) {
|
|
if (newLastMessages.isEmpty) {
|
|
// there are no messages at all
|
|
currentMessage = null;
|
|
previewMessages = [];
|
|
} else if (newMessagesNotOpened.isEmpty) {
|
|
// there are no not opened messages show just the last message in the table
|
|
currentMessage = newLastMessages.last;
|
|
previewMessages = newLastMessages;
|
|
} else {
|
|
// filter first for received messages
|
|
final receivedMessages =
|
|
newMessagesNotOpened.where((x) => x.messageOtherId != null).toList();
|
|
|
|
if (receivedMessages.isNotEmpty) {
|
|
previewMessages = receivedMessages;
|
|
currentMessage = receivedMessages.first;
|
|
} else {
|
|
previewMessages = newMessagesNotOpened;
|
|
currentMessage = newMessagesNotOpened.first;
|
|
}
|
|
}
|
|
|
|
final msgs =
|
|
previewMessages.where((x) => x.kind == MessageKind.media).toList();
|
|
if (msgs.isNotEmpty &&
|
|
msgs.first.kind == MessageKind.media &&
|
|
msgs.first.messageOtherId != null &&
|
|
msgs.first.openedAt == null) {
|
|
hasNonOpenedMediaFile = true;
|
|
} else {
|
|
hasNonOpenedMediaFile = false;
|
|
}
|
|
|
|
lastMessages = newLastMessages;
|
|
messagesNotOpened = newMessagesNotOpened;
|
|
setState(() {
|
|
// sets lastMessages, messagesNotOpened and currentMessage
|
|
});
|
|
}
|
|
|
|
Future<void> onTap() async {
|
|
if (currentMessage == null) {
|
|
await Navigator.push(context, MaterialPageRoute(
|
|
builder: (context) {
|
|
return CameraSendToView(widget.user);
|
|
},
|
|
));
|
|
return;
|
|
}
|
|
|
|
if (hasNonOpenedMediaFile) {
|
|
final msgs =
|
|
previewMessages.where((x) => x.kind == MessageKind.media).toList();
|
|
switch (msgs.first.downloadState) {
|
|
case DownloadState.pending:
|
|
await startDownloadMedia(msgs.first, true);
|
|
return;
|
|
case DownloadState.downloaded:
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return MediaViewerView(widget.user);
|
|
}),
|
|
);
|
|
return;
|
|
case DownloadState.downloading:
|
|
return;
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) {
|
|
return ChatMessagesView(widget.user);
|
|
}),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final flameCounter = getFlameCounterFromContact(widget.user);
|
|
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 50,
|
|
child: SizedBox(
|
|
key: widget.firstUserListItemKey,
|
|
height: 20,
|
|
width: 20,
|
|
),
|
|
),
|
|
UserContextMenu(
|
|
contact: widget.user,
|
|
child: ListTile(
|
|
title: Text(
|
|
getContactDisplayName(widget.user),
|
|
),
|
|
subtitle: (widget.user.deleted)
|
|
? Text(context.lang.userDeletedAccount)
|
|
: (currentMessage == null)
|
|
? Text(context.lang.chatsTapToSend)
|
|
: Row(
|
|
children: [
|
|
MessageSendStateIcon(previewMessages),
|
|
const Text('•'),
|
|
const SizedBox(width: 5),
|
|
if (currentMessage != null)
|
|
LastMessageTime(message: currentMessage!),
|
|
if (flameCounter > 0)
|
|
FlameCounterWidget(
|
|
widget.user,
|
|
flameCounter,
|
|
prefix: true,
|
|
),
|
|
],
|
|
),
|
|
leading: ContactAvatar(contact: widget.user),
|
|
trailing: (widget.user.deleted)
|
|
? null
|
|
: IconButton(
|
|
onPressed: () {
|
|
Navigator.push(context, MaterialPageRoute(
|
|
builder: (context) {
|
|
if (hasNonOpenedMediaFile) {
|
|
return ChatMessagesView(widget.user);
|
|
} else {
|
|
return CameraSendToView(widget.user);
|
|
}
|
|
},
|
|
));
|
|
},
|
|
icon: FaIcon(
|
|
hasNonOpenedMediaFile
|
|
? FontAwesomeIcons.solidComments
|
|
: FontAwesomeIcons.camera,
|
|
color: context.color.outline.withAlpha(150),
|
|
),
|
|
),
|
|
onTap: onTap,
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|