Core Function

- Chat page available
- File transfer available
This commit is contained in:
Linloir 2022-10-15 15:30:09 +08:00
parent 2a78af4885
commit c3d0a91c48
No known key found for this signature in database
GPG Key ID: 58EEB209A0F2C366
31 changed files with 1046 additions and 77 deletions

View File

@ -1,12 +1,16 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 14:03:16
* @LastEditTime : 2022-10-14 11:58:34
* @LastEditTime : 2022-10-15 10:54:53
* @Description :
*/
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tcp_client/chat/cubit/chat_cubit.dart';
import 'package:tcp_client/chat/cubit/chat_state.dart';
import 'package:tcp_client/chat/view/history_tile.dart';
import 'package:tcp_client/chat/view/input_box/input_box.dart';
import 'package:tcp_client/common/username/username.dart';
import 'package:tcp_client/repositories/local_service_repository/local_service_repository.dart';
import 'package:tcp_client/repositories/tcp_repository/tcp_repository.dart';
@ -42,13 +46,66 @@ class ChatPage extends StatelessWidget {
Widget build(BuildContext context) {
return RepositoryProvider<UserRepository>.value(
value: userRepository,
child: Scaffold(
appBar: AppBar(
title: UserNameText(
userid: userID,
fontWeight: FontWeight.bold,
)
child: BlocProvider<ChatCubit>(
create: (context) => ChatCubit(
userID: userID,
localServiceRepository: localServiceRepository,
tcpRepository: tcpRepository
),
child: Scaffold(
appBar: AppBar(
title: UserNameText(
userid: userID,
fontWeight: FontWeight.bold,
)
),
body: Column(
children: [
Expanded(
child: BlocBuilder<ChatCubit, ChatState>(
builder: (context, state) {
return ListView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
reverse: true,
itemBuilder: (context, index) {
if(index == state.chatHistory.length) {
//Load more
context.read<ChatCubit>().fetchHistory();
//Show loading indicator
return const Align(
alignment: Alignment.center,
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.blue,
strokeWidth: 2.0,
),
),
);
}
else {
//Return history tile
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8
),
child: HistoryTile(
history: state.chatHistory[index],
),
);
}
},
itemCount: state.status == ChatStatus.full ? state.chatHistory.length : state.chatHistory.length + 1,
);
},
),
),
InputBox()
]
),
)
),
);
}

View File

@ -1,44 +1,233 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 14:03:56
* @LastEditTime : 2022-10-14 13:47:33
* @LastEditTime : 2022-10-15 11:45:04
* @Description :
*/
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tcp_client/chat/cubit/chat_state.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
import 'package:tcp_client/repositories/local_service_repository/local_service_repository.dart';
import 'package:tcp_client/repositories/tcp_repository/models/tcp_request.dart';
import 'package:tcp_client/repositories/tcp_repository/models/tcp_response.dart';
import 'package:tcp_client/repositories/tcp_repository/tcp_repository.dart';
class ChatCubit extends Cubit<ChatState> {
ChatCubit({
required this.userID,
required this.localServiceRepository,
required this.tcpRepository
}): super(ChatState.empty()) {
subscription = tcpRepository.responseStreamBroadcast.listen(_onResponse);
}
final int userID;
final LocalServiceRepository localServiceRepository;
final TCPRepository tcpRepository;
late final StreamSubscription subscription;
final Map<String, StreamSubscription> messageSendSubscriptionMap = {};
final Map<String, StreamSubscription> fileFetchSubscriptionMap = {};
void addMessage(Message message) {
Future<void> addMessage(Message message) async {
//Store locally
localServiceRepository.storeMessages([message]);
//Send to server
tcpRepository.pushRequest(SendMessageRequest(
message: message,
token: (await SharedPreferences.getInstance()).getInt('token')
));
//Emit new state
var newHistory = ChatHistory(
message: message,
type: ChatHistoryType.outcome,
status: ChatHistoryStatus.sending
);
var newHistoryList = [newHistory, ...state.chatHistory];
emit(state.copyWith(chatHistory: newHistoryList));
_bindSubscriptionForSending(messageMd5: message.contentmd5);
}
Future<void> fetchHistory() async {
emit(state.copyWith(status: ChatStatus.fetching));
//Pull 20 histories from database
var fetchedMessages = await localServiceRepository.fetchMessageHistory(
userID: userID,
position: state.chatHistory.length
);
var newHistories = [];
for(var message in fetchedMessages) {
var history = ChatHistory(
message: message,
type: message.senderID == userID ? ChatHistoryType.income : ChatHistoryType.outcome,
status: ChatHistoryStatus.done
);
newHistories.add(history);
}
emit(state.copyWith(
status: fetchedMessages.length == 20 ? ChatStatus.partial : ChatStatus.full,
chatHistory: [...state.chatHistory, ...newHistories]
));
}
Future<void> openFile({required Message message}) async {
if(message.type != MessageType.file) {
return;
}
var file = await localServiceRepository.findFile(
filemd5: message.filemd5!,
fileName: message.contentDecoded
);
if(file != null) {
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == message.contentmd5);
if(index == -1) {
return;
}
newHistory[index] = newHistory[index].copyWith(status: ChatHistoryStatus.done);
emit(state.copyWith(chatHistory: newHistory));
OpenFile.open(file.path);
}
else {
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == message.contentmd5);
if(index == -1) {
return;
}
newHistory[index] = newHistory[index].copyWith(status: ChatHistoryStatus.downloading);
emit(state.copyWith(chatHistory: newHistory));
fetchFile(messageMd5: message.contentmd5);
}
}
Future<void> fetchFile({required String messageMd5}) async {
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == messageMd5);
if(index != -1) {
newHistory[index] = newHistory[index].copyWith(
status: ChatHistoryStatus.done
);
}
var clonedTCPRepository = await tcpRepository.clone();
clonedTCPRepository.pushRequest(FetchFileRequest(
msgmd5: messageMd5,
token: (await SharedPreferences.getInstance()).getInt('token')
));
var subscription = clonedTCPRepository.responseStreamBroadcast.listen((response) {
if(response.type == TCPResponseType.fetchFile) {
response as FetchFileResponse;
if(response.status == TCPResponseStatus.ok) {
fileFetchSubscriptionMap[messageMd5]?.cancel();
fileFetchSubscriptionMap.remove(messageMd5);
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == messageMd5);
if(index != -1) {
newHistory[index] = newHistory[index].copyWith(
status: ChatHistoryStatus.done
);
}
localServiceRepository.storeFile(tempFile: response.payload);
emit(state.copyWith(chatHistory: newHistory));
}
else {
fileFetchSubscriptionMap[messageMd5]?.cancel();
fileFetchSubscriptionMap.remove(messageMd5);
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == messageMd5);
if(index != -1) {
newHistory[index] = newHistory[index].copyWith(
status: ChatHistoryStatus.failed
);
}
emit(state.copyWith(chatHistory: newHistory));
}
}
});
fileFetchSubscriptionMap.addEntries([MapEntry(messageMd5, subscription)]);
}
void _onResponse(TCPResponse response) {
if(response.type == TCPResponseType.forwardMessage) {
response as ForwardMessageResponse;
if(response.message.senderID == userID || response.message.recieverID == userID) {
// Message storage will be handled by home bloc listener
// addMessage(response.message);
//Emit new state
var newHistory = ChatHistory(
message: response.message,
type: response.message.senderID == userID ? ChatHistoryType.income : ChatHistoryType.outcome,
status: ChatHistoryStatus.done
);
var newHistoryList = [newHistory, ...state.chatHistory];
emit(state.copyWith(chatHistory: newHistoryList));
}
}
else if(response.type == TCPResponseType.fetchMessage) {
response as FetchMessageResponse;
List<ChatHistory> fetchedHistories = [];
for(var message in response.messages) {
if(message.senderID == userID || message.recieverID == userID) {
// addMessage(message);
var newHistory = ChatHistory(
message: message,
type: message.senderID == userID ? ChatHistoryType.income : ChatHistoryType.outcome,
status: ChatHistoryStatus.done
);
fetchedHistories.insert(0, newHistory);
}
}
var newHistoryList = [...fetchedHistories, ...state.chatHistory];
emit(state.copyWith(chatHistory: newHistoryList));
}
}
void _bindSubscriptionForSending({
required String messageMd5
}) async {
var subscription = tcpRepository.responseStreamBroadcast.listen((response) {
if(response.type == TCPResponseType.sendMessage) {
response as SendMessageResponse;
if(response.md5encoded == messageMd5) {
messageSendSubscriptionMap[messageMd5]?.cancel();
messageSendSubscriptionMap.remove(messageMd5);
if(response.status == TCPResponseStatus.ok) {
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == messageMd5);
if(index != -1) {
newHistory[index] = newHistory[index].copyWith(
status: ChatHistoryStatus.done
);
}
emit(state.copyWith(chatHistory: newHistory));
}
else {
var newHistory = [...state.chatHistory];
var index = newHistory.indexWhere((e) => e.message.contentmd5 == messageMd5);
if(index != -1) {
newHistory[index] = newHistory[index].copyWith(
status: ChatHistoryStatus.failed
);
}
emit(state.copyWith(chatHistory: newHistory));
}
}
}
});
messageSendSubscriptionMap.addEntries([MapEntry(messageMd5, subscription)]);
}
@override
Future<void> close() {
subscription.cancel();
for(var sub in messageSendSubscriptionMap.values) {
sub.cancel();
}
return super.close();
}
}

View File

@ -1,23 +1,33 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 14:03:52
* @LastEditTime : 2022-10-14 13:42:46
* @LastEditTime : 2022-10-14 23:04:07
* @Description :
*/
import 'package:equatable/equatable.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
enum ChatStatus { fetching, partial, full }
class ChatState extends Equatable {
final ChatStatus status;
final List<Message> chatHistory;
final List<ChatHistory> chatHistory;
const ChatState({required this.chatHistory, required this.status});
static ChatState empty() => const ChatState(chatHistory: [], status: ChatStatus.fetching);
ChatState copyWith({
ChatStatus? status,
List<ChatHistory>? chatHistory
}) {
return ChatState(
status: status ?? this.status,
chatHistory: chatHistory ?? this.chatHistory
);
}
@override
List<Object> get props => [chatHistory];
List<Object> get props => [...chatHistory, status];
}

View File

@ -0,0 +1,39 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 14:55:20
* @LastEditTime : 2022-10-14 15:26:25
* @Description :
*/
import 'package:equatable/equatable.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
enum ChatHistoryType { outcome, income }
enum ChatHistoryStatus { none, sending, downloading, done, failed }
class ChatHistory extends Equatable {
final Message message;
final ChatHistoryType type;
final ChatHistoryStatus status;
const ChatHistory({
required this.message,
required this.type,
required this.status
});
ChatHistory copyWith({
Message? message,
ChatHistoryType? type,
ChatHistoryStatus? status
}) {
return ChatHistory(
message: message ?? this.message,
type: type ?? this.type,
status: status ?? this.status
);
}
@override
List<Object> get props => [message.contentmd5, type, status];
}

View File

@ -0,0 +1,78 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 17:07:13
* @LastEditTime : 2022-10-15 11:40:47
* @Description :
*/
import 'package:easy_debounce/easy_debounce.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:tcp_client/chat/cubit/chat_cubit.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
class FileBox extends StatelessWidget {
const FileBox({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: (history.status == ChatHistoryStatus.downloading || history.status == ChatHistoryStatus.sending) ? null : () {
EasyDebounce.debounce(
'findfile${history.message.contentmd5}',
const Duration(milliseconds: 500),
() {
context.read<ChatCubit>().openFile(
message: history.message
);
}
);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
child: history.status == ChatHistoryStatus.none || history.status == ChatHistoryStatus.done ?
Icon(
Icons.file_present_rounded,
size: 24,
color: history.type == ChatHistoryType.income ? Colors.blue[800] : Colors.white.withOpacity(0.8),
) : history.status == ChatHistoryStatus.failed ?
Icon(
Icons.refresh_rounded,
size: 24,
color: history.type == ChatHistoryType.income ? Colors.red[800] : Colors.white.withOpacity(0.8),
) :
SizedBox(
height: 18.0,
width: 18.0,
child: CircularProgressIndicator(
color: history.type == ChatHistoryType.income ? Colors.blue[800] : Colors.white.withOpacity(0.8),
strokeWidth: 3,
),
)
),
const SizedBox(width: 18.0,),
Flexible(
child: Text(
history.message.contentDecoded,
softWrap: true,
style: TextStyle(
fontSize: 20.0,
color: history.type == ChatHistoryType.income ? Colors.grey[900] : Colors.white
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,28 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 17:04:20
* @LastEditTime : 2022-10-14 17:34:12
* @Description :
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
class ImageBox extends StatelessWidget {
const ImageBox({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: (){},
child: Image.memory(base64Decode(history.message.contentDecoded))
);
}
}

View File

@ -0,0 +1,94 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 17:04:12
* @LastEditTime : 2022-10-15 10:53:28
* @Description :
*/
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tcp_client/chat/cubit/chat_cubit.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
import 'package:tcp_client/repositories/tcp_repository/models/tcp_request.dart';
class TextBox extends StatelessWidget {
const TextBox({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: (){},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if(history.type == ChatHistoryType.outcome)
...[
if(history.status == ChatHistoryStatus.sending)
...[
SizedBox(
height: 15.0,
width: 15.0,
child: CircularProgressIndicator(
color: Colors.white.withOpacity(0.5),
strokeWidth: 2.0,
),
),
const SizedBox(width: 16.0,),
],
if(history.status == ChatHistoryStatus.failed)
...[
ClipOval(
child: Material(
color: Colors.transparent,
child: InkWell(
child: Icon(
Icons.error_rounded,
color: Colors.white.withOpacity(0.5),
size: 20,
),
onTap: () async {
context.read<ChatCubit>().tcpRepository.pushRequest(SendMessageRequest(
message: history.message,
token: (await SharedPreferences.getInstance()).getInt('token')
));
},
),
),
),
const SizedBox(width: 8.0,),
],
if(history.status == ChatHistoryStatus.done)
...[
Icon(
Icons.check_rounded,
color: Colors.white.withOpacity(0.5),
size: 20,
),
const SizedBox(width: 12.0,),
],
],
Flexible(
child: Text(
history.message.contentDecoded,
softWrap: true,
style: TextStyle(
fontSize: 18,
color: history.type == ChatHistoryType.income ? Colors.grey[900] : Colors.white
),
),
),
]
)
),
);
}
}

View File

@ -1,6 +1,69 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 14:03:45
* @LastEditTime : 2022-10-13 14:03:46
* @LastEditTime : 2022-10-15 10:52:30
* @Description :
*/
import 'package:flutter/material.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
import 'package:tcp_client/chat/view/in_message_box.dart';
import 'package:tcp_client/chat/view/out_message_box.dart';
import 'package:tcp_client/common/avatar/avatar.dart';
class HistoryTile extends StatelessWidget {
const HistoryTile({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: history.type == ChatHistoryType.income ? MainAxisAlignment.start : MainAxisAlignment.end,
children: [
if(history.type == ChatHistoryType.income)
...[
Expanded(
flex: 5,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
UserAvatar(userid: history.message.senderID),
const SizedBox(width: 16.0,),
Flexible(
child: InMessageBox(history: history)
),
],
)
),
const Spacer(flex: 1,),
],
if(history.type == ChatHistoryType.outcome)
...[
const Spacer(flex: 1,),
Expanded(
flex: 5,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: OutMessageBox(history: history),
),
const SizedBox(width: 16.0,),
UserAvatar(userid: history.message.senderID),
],
)
),
]
],
);
}
}

View File

@ -0,0 +1,91 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 13:49:47
* @LastEditTime : 2022-10-15 10:24:35
* @Description :
*/
import 'package:flutter/material.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
import 'package:tcp_client/chat/view/common/file_box.dart';
import 'package:tcp_client/chat/view/common/image_box.dart';
import 'package:tcp_client/chat/view/common/text_box.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
class InMessageBox extends StatelessWidget {
const InMessageBox({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
key: ValueKey(history.message.contentmd5),
duration: const Duration(milliseconds: 375),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8
),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8.0),
topRight: Radius.circular(8.0),
bottomLeft: Radius.zero,
bottomRight: Radius.circular(8.0)
),
boxShadow: [BoxShadow(blurRadius: 5.0, color: Colors.grey.withOpacity(0.3))]
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8.0),
topRight: Radius.circular(8.0),
bottomLeft: Radius.zero,
bottomRight: Radius.circular(8.0)
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if(history.message.type == MessageType.file)
FileBox(history: history),
if(history.message.type == MessageType.image)
ImageBox(history: history),
if(history.message.type == MessageType.plaintext)
TextBox(history: history),
const SizedBox(height: 4.0,),
Text(
_getTimeStamp(history.message.timeStamp),
style: TextStyle(
fontSize: 12,
color: Colors.grey[400],
),
)
],
),
)
);
}
String _getTimeStamp(int timeStamp) {
var date = DateTime.fromMillisecondsSinceEpoch(timeStamp);
var weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
//If date is today, return time
if(date.day == DateTime.now().day) {
return '${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//If date is yesterday, return 'yesterday'
if(date.day == DateTime.now().day - 1) {
return 'yesterday ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//If date is within this week, return the weekday in english
if(date.weekday < DateTime.now().weekday) {
return '${weekdays[date.weekday - 1]} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//Otherwise return the date in english
return '${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
}

View File

@ -1,6 +0,0 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 13:49:47
* @LastEditTime : 2022-10-14 13:49:47
* @Description :
*/

View File

@ -0,0 +1,45 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 21:57:05
* @LastEditTime : 2022-10-14 22:55:16
* @Description :
*/
import 'package:bloc/bloc.dart';
import 'package:formz/formz.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tcp_client/chat/cubit/chat_cubit.dart';
import 'package:tcp_client/chat/view/input_box/cubit/input_state.dart';
import 'package:tcp_client/chat/view/input_box/model/input.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
import 'package:tcp_client/repositories/local_service_repository/local_service_repository.dart';
import 'package:tcp_client/repositories/tcp_repository/tcp_repository.dart';
class MessageInputCubit extends Cubit<MessageInputState> {
MessageInputCubit({
required this.chatCubit,
}): super(const MessageInputState());
final ChatCubit chatCubit;
void onInputChange(MessageInput input) {
emit(state.copyWith(
status: Formz.validate([input]),
input: input
));
}
Future<void> onSubmission() async {
chatCubit.addMessage(Message(
userid: (await SharedPreferences.getInstance()).getInt('userid')!,
targetid: chatCubit.userID,
contenttype: MessageType.plaintext,
content: state.input.value,
token: (await SharedPreferences.getInstance()).getInt('token')!
));
emit(state.copyWith(
status: FormzStatus.pure,
input: const MessageInput.pure()
));
}
}

View File

@ -0,0 +1,34 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 21:56:53
* @LastEditTime : 2022-10-14 21:59:51
* @Description :
*/
import 'package:equatable/equatable.dart';
import 'package:formz/formz.dart';
import 'package:tcp_client/chat/view/input_box/model/input.dart';
class MessageInputState extends Equatable {
final MessageInput input;
final FormzStatus status;
const MessageInputState({
this.status = FormzStatus.pure,
this.input = const MessageInput.pure()
});
MessageInputState copyWith({
FormzStatus? status,
MessageInput? input
}) {
return MessageInputState(
input: input ?? this.input,
status: status ?? this.status
);
}
@override
List<Object> get props => [status, input];
}

View File

@ -0,0 +1,86 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 17:54:30
* @LastEditTime : 2022-10-15 00:27:39
* @Description :
*/
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tcp_client/chat/cubit/chat_cubit.dart';
import 'package:tcp_client/chat/view/input_box/cubit/input_cubit.dart';
import 'package:tcp_client/chat/view/input_box/cubit/input_state.dart';
import 'package:tcp_client/chat/view/input_box/model/input.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
import 'package:tcp_client/repositories/tcp_repository/models/tcp_request.dart';
class InputBox extends StatelessWidget {
InputBox({super.key});
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return BlocProvider<MessageInputCubit>(
create:(context) => MessageInputCubit(
chatCubit: context.read<ChatCubit>()
),
child: SizedBox(
height: 64,
child: Row(
children: [
Expanded(
child: BlocListener<MessageInputCubit, MessageInputState>(
listenWhen: (previous, current) => previous.status != FormzStatus.pure && current.status == FormzStatus.pure,
listener: (context, state) {
_controller.clear();
},
child: Builder(
builder: (context) => TextField(
controller: _controller,
onChanged: (value) {
context.read<MessageInputCubit>().onInputChange(MessageInput.dirty(value));
},
),
)
),
),
IconButton(
onPressed: () {
var chatCubit = context.read<ChatCubit>();
chatCubit.localServiceRepository.pickFile(FileType.any).then((file) async {
if(file != null) {
var newMessage = Message(
userid: (await SharedPreferences.getInstance()).getInt('userid')!,
targetid: chatCubit.userID,
content: basename(file.file.path),
contenttype: MessageType.file,
payload: file,
token: (await SharedPreferences.getInstance()).getInt('token')!
);
chatCubit.addMessage(newMessage);
}
});
},
icon: const Icon(Icons.attach_file_rounded)
),
BlocBuilder<MessageInputCubit, MessageInputState>(
builder:(context, state) {
return IconButton(
onPressed: state.status == FormzStatus.valid ? () {
context.read<MessageInputCubit>().onSubmission();
} : null,
icon: const Icon(Icons.send_rounded),
);
},
)
],
),
),
);
}
}

View File

@ -0,0 +1,21 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 21:57:44
* @LastEditTime : 2022-10-14 21:57:44
* @Description :
*/
import 'package:formz/formz.dart';
enum MessageInputValidationError { empty }
class MessageInput extends FormzInput<String, MessageInputValidationError> {
const MessageInput.pure() : super.pure('');
const MessageInput.dirty([super.value = '']) : super.dirty();
@override
MessageInputValidationError? validator(String? value) {
return value?.isNotEmpty == true ? null : MessageInputValidationError.empty;
}
}

View File

@ -0,0 +1,91 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 13:49:28
* @LastEditTime : 2022-10-15 10:23:43
* @Description :
*/
import 'package:flutter/material.dart';
import 'package:tcp_client/chat/model/chat_history.dart';
import 'package:tcp_client/chat/view/common/file_box.dart';
import 'package:tcp_client/chat/view/common/image_box.dart';
import 'package:tcp_client/chat/view/common/text_box.dart';
import 'package:tcp_client/repositories/common_models/message.dart';
class OutMessageBox extends StatelessWidget {
const OutMessageBox({
required this.history,
super.key
});
final ChatHistory history;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
key: ValueKey(history.message.contentmd5),
duration: const Duration(milliseconds: 375),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8
),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8.0),
topRight: Radius.circular(8.0),
bottomLeft: Radius.circular(8.0),
bottomRight: Radius.zero
),
boxShadow: [BoxShadow(blurRadius: 5.0, color: Colors.grey.withOpacity(0.2))]
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8.0),
topRight: Radius.circular(8.0),
bottomLeft: Radius.circular(8.0),
bottomRight: Radius.zero
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if(history.message.type == MessageType.file)
FileBox(history: history),
if(history.message.type == MessageType.image)
ImageBox(history: history),
if(history.message.type == MessageType.plaintext)
TextBox(history: history),
const SizedBox(height: 4.0,),
Text(
_getTimeStamp(history.message.timeStamp),
style: TextStyle(
fontSize: 12,
color: Colors.grey[200],
),
)
],
),
)
);
}
String _getTimeStamp(int timeStamp) {
var date = DateTime.fromMillisecondsSinceEpoch(timeStamp);
var weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
//If date is today, return time
if(date.day == DateTime.now().day) {
return '${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//If date is yesterday, return 'yesterday'
if(date.day == DateTime.now().day - 1) {
return 'yesterday ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//If date is within this week, return the weekday in english
if(date.weekday < DateTime.now().weekday) {
return '${weekdays[date.weekday - 1]} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//Otherwise return the date in english
return '${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
}

View File

@ -1,6 +0,0 @@
/*
* @Author : Linloir
* @Date : 2022-10-14 13:49:28
* @LastEditTime : 2022-10-14 13:49:28
* @Description :
*/

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 11:05:08
* @LastEditTime : 2022-10-14 10:52:26
* @LastEditTime : 2022-10-14 15:57:30
* @Description :
*/
@ -26,6 +26,7 @@ class HomePage extends StatelessWidget {
required this.tcpRepository,
super.key
});
//TODO: listen to file storage
final int userID;
final LocalServiceRepository localServiceRepository;

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 14:01:45
* @LastEditTime : 2022-10-14 11:49:50
* @LastEditTime : 2022-10-14 23:03:02
* @Description :
*/
@ -22,7 +22,7 @@ class ContactCubit extends Cubit<ContactState> {
}): super(ContactState.empty()) {
subscription = tcpRepository.responseStreamBroadcast.listen(_onResponse);
updateContacts();
timer = Timer.periodic(const Duration(seconds: 5), (timer) {updateContacts();});
timer = Timer.periodic(const Duration(seconds: 10), (timer) {updateContacts();});
}
final LocalServiceRepository localServiceRepository;

View File

@ -1,15 +1,16 @@
/*
* @Author : Linloir
* @Date : 2022-10-12 23:38:31
* @LastEditTime : 2022-10-13 22:27:29
* @LastEditTime : 2022-10-15 10:29:05
* @Description :
*/
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tcp_client/home/view/message_page/cubit/msg_list_state.dart';
import 'package:tcp_client/home/view/message_page/models/message_info.dart';
import 'package:tcp_client/repositories/common_models/userinfo.dart';
import 'package:tcp_client/repositories/local_service_repository/local_service_repository.dart';
import 'package:tcp_client/repositories/tcp_repository/models/tcp_response.dart';
import 'package:tcp_client/repositories/tcp_repository/tcp_repository.dart';
@ -19,11 +20,12 @@ class MessageListCubit extends Cubit<MessageListState> {
required this.localServiceRepository,
required this.tcpRepository
}): super(MessageListState.empty()) {
tcpRepository.responseStreamBroadcast.listen(_onResponse);
subscription = tcpRepository.responseStreamBroadcast.listen(_onResponse);
}
final LocalServiceRepository localServiceRepository;
final TCPRepository tcpRepository;
late final StreamSubscription subscription;
void addEmptyMessageOf({required int targetUser}) {
if(state.messageList.any((element) => element.targetUser == targetUser)) {
@ -35,6 +37,20 @@ class MessageListCubit extends Cubit<MessageListState> {
Future<void> _onResponse(TCPResponse response) async {
switch(response.type) {
case TCPResponseType.sendMessage: {
response as SendMessageResponse;
if(response.status == TCPResponseStatus.ok) {
var message = await localServiceRepository.fetchMessage(msgmd5: response.md5encoded!);
if(message != null) {
var curUser = (await SharedPreferences.getInstance()).getInt('userid');
emit(state.updateWithSingle(messageInfo: MessageInfo(
message: message,
targetUser: message.senderID == curUser ? message.recieverID : message.senderID
)));
}
}
break;
}
case TCPResponseType.fetchMessage: {
response as FetchMessageResponse;
Set<int> addedUserSet = {};
@ -82,4 +98,10 @@ class MessageListCubit extends Cubit<MessageListState> {
default: break;
}
}
@override
Future<void> close() {
subscription.cancel();
return super.close();
}
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-12 23:37:49
* @LastEditTime : 2022-10-13 22:24:22
* @LastEditTime : 2022-10-15 00:55:49
* @Description :
*/
@ -104,5 +104,5 @@ class MessageListState extends Equatable {
}
@override
List<Object> get props => [messageList];
List<Object> get props => [...messageList];
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 11:05:18
* @LastEditTime : 2022-10-14 11:45:45
* @LastEditTime : 2022-10-15 10:20:43
* @Description :
*/
@ -18,8 +18,7 @@ class MessagePage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<MessageListCubit, MessageListState>(
builder: (context, state) {
return Container(
child: ListView.separated(
return ListView.separated(
itemBuilder: (context, index) {
return MessageTile(
userID: state.messageList[index].targetUser,
@ -32,8 +31,7 @@ class MessagePage extends StatelessWidget {
);
},
itemCount: state.messageList.length
),
);
);
}
);
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-12 23:48:54
* @LastEditTime : 2022-10-13 22:24:01
* @LastEditTime : 2022-10-15 01:01:48
* @Description :
*/
@ -19,5 +19,5 @@ class MessageInfo extends Equatable {
});
@override
List<Object?> get props => [message?.contentmd5, targetUser];
List<Object> get props => [message?.contentmd5 ?? '', targetUser];
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-13 13:17:52
* @LastEditTime : 2022-10-14 12:07:32
* @LastEditTime : 2022-10-15 01:05:11
* @Description :
*/
@ -88,18 +88,20 @@ class MessageTile extends StatelessWidget {
const SizedBox(width: 16,),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 6,),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
vertical: 2.0,
horizontal: 0
),
child: UserNameText(userid: userID, fontWeight: FontWeight.bold,)
),
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0
vertical: 2.0
),
child: Text(
message?.contentDecoded ?? '',
@ -107,7 +109,8 @@ class MessageTile extends StatelessWidget {
fontSize: 16,
),
),
)
),
const SizedBox(height: 6,),
],
),
),
@ -138,7 +141,7 @@ class MessageTile extends StatelessWidget {
var weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
//If date is today, return time
if(date.day == DateTime.now().day) {
return '${date.hour}:${date.minute}';
return '${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
//If date is yesterday, return 'yesterday'
if(date.day == DateTime.now().day - 1) {

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-12 09:56:04
* @LastEditTime : 2022-10-14 14:24:11
* @LastEditTime : 2022-10-14 16:47:04
* @Description :
*/
@ -22,7 +22,6 @@ class InitializationCubit extends Cubit<InitializationState> {
TCPRepository? tcpRepository;
LocalServiceRepository? localServiceRepository;
Future(() async {
print('${(await getApplicationDocumentsDirectory()).path}/.data/database.db');
localServiceRepository = await LocalServiceRepository.create(databaseFilePath: '${(await getApplicationDocumentsDirectory()).path}/.data/database.db');
}).then((_) {
emit(state.copyWith(

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 10:30:05
* @LastEditTime : 2022-10-11 23:35:45
* @LastEditTime : 2022-10-14 23:53:18
* @Description :
*/
@ -50,7 +50,7 @@ class Message extends JSONEncodable {
_timestamp = DateTime.now().millisecondsSinceEpoch,
_payload = payload {
_contentmd5 = md5.convert(
utf8.encode(content)
utf8.encode(content).toList()
..addAll(Uint8List(4)..buffer.asInt32List()[0] = userid)
..addAll(Uint8List(4)..buffer.asInt32List()[0] = targetid)
..addAll(Uint8List(4)..buffer.asInt32List()[0] = _timestamp)
@ -68,7 +68,7 @@ class Message extends JSONEncodable {
_content = jsonObject['content'] as String,
_timestamp = jsonObject['timestamp'] as int,
_contentmd5 = jsonObject['md5encoded'] as String,
_filemd5 = jsonObject['filemd5'] as String,
_filemd5 = jsonObject['filemd5'] as String?,
_payload = payload;
int get senderID => _userid;
@ -88,7 +88,7 @@ class Message extends JSONEncodable {
'contenttype': _contenttype.literal,
'content': _content,
'timestamp': _timestamp,
'md5Encoded': _contentmd5,
'md5encoded': _contentmd5,
'filemd5': payload?.filemd5
};
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 10:56:02
* @LastEditTime : 2022-10-14 14:30:11
* @LastEditTime : 2022-10-15 11:48:54
* @Description : Local Service Repository
*/
@ -40,7 +40,7 @@ class LocalServiceRepository {
content text not null,
timestamp int not null,
md5encoded text primary key,
filemd5 text not null
filemd5 text
);
create table files (
filemd5 text primary key,
@ -79,17 +79,15 @@ class LocalServiceRepository {
}
Future<void> storeMessages(List<Message> messages) async {
for(var message in messages) {
try {
await _database.insert(
await _database.transaction((txn) async {
for(var message in messages) {
await txn.insert(
'msgs',
message.jsonObject,
conflictAlgorithm: ConflictAlgorithm.replace
);
} catch (err) {
//TODO: do something
}
}
});
}
Future<List<Message>> findMessages({required String pattern}) async {
@ -216,13 +214,20 @@ class LocalServiceRepository {
await Directory('$documentPath/files/.lib').create();
var permanentFilePath = '$documentPath/files/.lib/${tempFile.filemd5}';
await tempFile.file.copy(permanentFilePath);
await _database.insert(
'files',
{
'filemd5': tempFile.filemd5,
'dir': permanentFilePath
}
);
try{
await _database.insert(
'files',
{
'filemd5': tempFile.filemd5,
'dir': permanentFilePath
},
conflictAlgorithm: ConflictAlgorithm.replace
);
} catch (err) {
print(err);
}
//Clear temp file
tempFile.file.delete();
}
final StreamController<UserInfo> _userInfoChangeStreamController = StreamController();
@ -273,4 +278,20 @@ class LocalServiceRepository {
Future<UserInfo?> fetchUserInfoViaUsername({required String username}) async {
}
Future<Message?> fetchMessage({required String msgmd5}) async {
var result = await _database.query(
'msgs',
where: 'md5encoded = ?',
whereArgs: [
msgmd5
]
);
if(result.isNotEmpty) {
return Message.fromJSONObject(jsonObject: result[0]);
}
else {
return null;
}
}
}

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 09:44:03
* @LastEditTime : 2022-10-13 23:04:49
* @LastEditTime : 2022-10-15 11:14:21
* @Description : Abstract TCP request class
*/
@ -167,7 +167,7 @@ class SendMessageRequest extends TCPRequest {
var jsonString = toJSON();
var requestLength = jsonString.length;
yield Uint8List(4)..buffer.asInt32List()[0] = requestLength;
yield Uint8List(4)..buffer.asInt32List()[0] = 0;
yield Uint8List(4)..buffer.asInt32List()[0] = (await _message.payload?.file.length()) ?? 0;
yield Uint8List.fromList(jsonString.codeUnits);
if(_message.payload != null) {
var fileStream = _message.payload!.file.openRead();

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 11:02:19
* @LastEditTime : 2022-10-14 12:16:04
* @LastEditTime : 2022-10-14 15:12:02
* @Description :
*/
@ -156,9 +156,15 @@ class ModifyProfileResponse extends TCPResponse {
}
class SendMessageResponse extends TCPResponse {
late final String? _md5encoded;
SendMessageResponse({
required Map<String, Object?> jsonObject
}): super(jsonObject: jsonObject);
}): super(jsonObject: jsonObject) {
_md5encoded = jsonObject['body'] == null ? null : (jsonObject['body'] as Map<String, Object?>)['md5encoded'] as String?;
}
String? get md5encoded => _md5encoded;
}
class ForwardMessageResponse extends TCPResponse {

View File

@ -1,7 +1,7 @@
/*
* @Author : Linloir
* @Date : 2022-10-11 09:42:05
* @LastEditTime : 2022-10-14 09:15:47
* @LastEditTime : 2022-10-15 11:06:05
* @Description : TCP repository
*/
@ -106,7 +106,7 @@ class TCPRepository {
remotePort: _remotePort
);
duplicatedRepository._requestStreamController.add(request);
await for(var response in duplicatedRepository.responseStream) {
await for(var response in duplicatedRepository.responseStreamBroadcast) {
if(response.type == TCPResponseType.sendMessage) {
_responseStreamController.add(response);
break;
@ -115,6 +115,9 @@ class TCPRepository {
duplicatedRepository.dispose();
});
}
else {
_requestStreamController.add(request);
}
}
else {
_requestStreamController.add(request);
@ -333,8 +336,9 @@ class TCPRepository {
return hasFile;
}
void dispose() {
_socket.close();
void dispose() async {
await _socket.flush();
await _socket.close();
_responseRawStreamController.close();
_payloadPullStreamController.close();
_payloadRawStreamController.close();

View File

@ -229,7 +229,7 @@ packages:
source: git
version: "3.2.2"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
url: "https://pub.flutter-io.cn"

View File

@ -54,6 +54,7 @@ dependencies:
azlistview: ^2.0.0
lpinyin: ^2.0.3
easy_debounce: ^2.0.2+1
path: ^1.8.2
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.