Initial commit

This commit is contained in:
Christian Pauly 2020-01-01 19:10:13 +01:00
commit b5f2ecd56f
96 changed files with 4522 additions and 0 deletions

237
lib/views/chat.dart Normal file
View file

@ -0,0 +1,237 @@
import 'dart:io';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/chat_settings_popup_menu.dart';
import 'package:fluffychat/components/list_items/message.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:toast/toast.dart';
import 'chat_list.dart';
class Chat extends StatefulWidget {
final String id;
const Chat(this.id, {Key key}) : super(key: key);
@override
_ChatState createState() => _ChatState();
}
class _ChatState extends State<Chat> {
Room room;
Timeline timeline;
final ScrollController _scrollController = new ScrollController();
@override
void initState() {
_scrollController.addListener(() async {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
if (timeline.events.length > 0 &&
timeline.events[timeline.events.length - 1].type !=
EventTypes.RoomCreate) {
await timeline.requestHistory(historyCount: 100);
}
}
});
super.initState();
}
Future<bool> getTimeline() async {
timeline ??= await room.getTimeline(onUpdate: () {
setState(() {});
});
return true;
}
@override
void dispose() {
timeline?.sub?.cancel();
super.dispose();
}
final TextEditingController sendController = TextEditingController();
void send() {
if (sendController.text.isEmpty) return;
room.sendTextEvent(sendController.text);
sendController.text = "";
}
void sendFileAction(BuildContext context) async {
if (kIsWeb) {
return Toast.show("Not supported in web", context);
}
File file = await FilePicker.getFile();
if (file == null) return;
Matrix.of(context).tryRequestWithLoadingDialog(
room.sendFileEvent(
MatrixFile(bytes: await file.readAsBytes(), path: file.path),
),
);
}
void sendImageAction(BuildContext context) async {
if (kIsWeb) {
return Toast.show("Not supported in web", context);
}
File file = await ImagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (file == null) return;
Matrix.of(context).tryRequestWithLoadingDialog(
room.sendImageEvent(
MatrixFile(bytes: await file.readAsBytes(), path: file.path),
),
);
}
void openCameraAction(BuildContext context) async {
if (kIsWeb) {
return Toast.show("Not supported in web", context);
}
File file = await ImagePicker.pickImage(
source: ImageSource.camera,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (file == null) return;
Matrix.of(context).tryRequestWithLoadingDialog(
room.sendImageEvent(
MatrixFile(bytes: await file.readAsBytes(), path: file.path),
),
);
}
@override
Widget build(BuildContext context) {
Client client = Matrix.of(context).client;
room ??= client.getRoomById(widget.id);
if (room.membership == Membership.invite) room.join();
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(
activeChat: widget.id,
),
secondScaffold: Scaffold(
appBar: AppBar(
title: Text(room.displayname),
actions: <Widget>[ChatSettingsPopupMenu(room, !room.isDirectChat)],
),
body: SafeArea(
child: Column(
children: <Widget>[
Expanded(
child: FutureBuilder<bool>(
future: getTimeline(),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData)
return Center(
child: CircularProgressIndicator(),
);
if (room.notificationCount != null &&
room.notificationCount > 0 &&
timeline != null &&
timeline.events.length > 0)
room.sendReadReceipt(timeline.events[0].eventId);
return ListView.builder(
reverse: true,
itemCount: timeline.events.length,
controller: _scrollController,
itemBuilder: (BuildContext context, int i) =>
Message(timeline.events[i]),
);
},
),
),
Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 1,
blurRadius: 2,
offset: Offset(0, -1), // changes position of shadow
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
kIsWeb
? Container()
: PopupMenuButton<String>(
icon: Icon(Icons.add),
onSelected: (String choice) async {
if (choice == "file")
sendFileAction(context);
else if (choice == "image")
sendImageAction(context);
if (choice == "camera") openCameraAction(context);
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: "file",
child: ListTile(
leading: Icon(Icons.attach_file),
title: Text('Send file'),
contentPadding: EdgeInsets.all(0),
),
),
const PopupMenuItem<String>(
value: "image",
child: ListTile(
leading: Icon(Icons.image),
title: Text('Send image'),
contentPadding: EdgeInsets.all(0),
),
),
const PopupMenuItem<String>(
value: "camera",
child: ListTile(
leading: Icon(Icons.camera),
title: Text('Open camera'),
contentPadding: EdgeInsets.all(0),
),
),
],
),
SizedBox(width: 8),
Expanded(
child: TextField(
onSubmitted: (t) => send(),
controller: sendController,
decoration: InputDecoration(
labelText: "Write a message...",
hintText: "You're message",
border: InputBorder.none,
),
)),
SizedBox(width: 8),
IconButton(
icon: Icon(Icons.send),
onPressed: () => send(),
),
],
),
),
],
),
),
),
);
}
}

164
lib/views/chat_details.dart Normal file
View file

@ -0,0 +1,164 @@
import 'dart:io';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/chat_settings_popup_menu.dart';
import 'package:fluffychat/components/content_banner.dart';
import 'package:fluffychat/components/list_items/participant_list_item.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/views/chat_list.dart';
import 'package:fluffychat/views/invitation_selection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:toast/toast.dart';
class ChatDetails extends StatefulWidget {
final Room room;
const ChatDetails(this.room);
@override
_ChatDetailsState createState() => _ChatDetailsState();
}
class _ChatDetailsState extends State<ChatDetails> {
List<User> members;
void setDisplaynameAction(BuildContext context, String displayname) async {
final MatrixState matrix = Matrix.of(context);
final Map<String, dynamic> success =
await matrix.tryRequestWithLoadingDialog(
widget.room.setName(displayname),
);
if (success != null && success.length == 0) {
Toast.show(
"Displayname has been changed",
context,
duration: Toast.LENGTH_LONG,
);
}
}
void setAvatarAction(BuildContext context) async {
final File tempFile = await ImagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (tempFile == null) return;
final MatrixState matrix = Matrix.of(context);
final Map<String, dynamic> success =
await matrix.tryRequestWithLoadingDialog(
widget.room.setAvatar(
MatrixFile(
bytes: await tempFile.readAsBytes(),
path: tempFile.path,
),
),
);
if (success != null && success.length == 0) {
Toast.show(
"Avatar has been changed",
context,
duration: Toast.LENGTH_LONG,
);
}
}
void requestMoreMembersAction(BuildContext context) async {
final List<User> participants = await Matrix.of(context)
.tryRequestWithLoadingDialog(widget.room.requestParticipants());
if (participants != null) setState(() => members = participants);
}
@override
Widget build(BuildContext context) {
members ??= widget.room.getParticipants();
final int actualMembersCount =
widget.room.mInvitedMemberCount + widget.room.mJoinedMemberCount;
final bool canRequestMoreMembers = members.length < actualMembersCount;
widget.room.onUpdate = () => setState(() => members = null);
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(
activeChat: widget.room.id,
),
secondScaffold: Scaffold(
appBar: AppBar(
title: Text(widget.room.displayname),
actions: <Widget>[ChatSettingsPopupMenu(widget.room, false)],
),
body: ListView.builder(
itemCount: members.length + 1 + (canRequestMoreMembers ? 1 : 0),
itemBuilder: (BuildContext context, int i) => i == 0
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ContentBanner(widget.room.avatar),
widget.room.canSendEvent("m.room.avatar") && !kIsWeb
? ListTile(
title: Text("Edit group avatar"),
trailing: Icon(Icons.file_upload),
onTap: () => setAvatarAction(context),
)
: Container(),
widget.room.canSendEvent("m.room.name")
? ListTile(
trailing: Icon(Icons.edit),
title: TextField(
textInputAction: TextInputAction.done,
onSubmitted: (s) =>
setDisplaynameAction(context, s),
decoration: InputDecoration(
border: InputBorder.none,
labelText: "Edit group name",
labelStyle: TextStyle(color: Colors.black),
hintText: (widget.room.displayname),
),
),
)
: Container(),
Row(
children: <Widget>[
Expanded(
child: Container(height: 8, color: Color(0xFFF8F8F8)),
),
SizedBox(width: 8),
Text(
"$actualMembersCount participant(s)",
style: TextStyle(
fontSize: 15,
),
),
SizedBox(width: 8),
Expanded(
child: Container(height: 8, color: Color(0xFFF8F8F8)),
),
],
),
ListTile(
title: Text("Invite contact"),
leading: Icon(Icons.add),
onTap: () => Navigator.of(context).push(
AppRoute.defaultRoute(
context,
InvitationSelection(widget.room),
),
),
),
],
)
: i < members.length + 1
? ParticipantListItem(members[i - 1])
: ListTile(
title: Text(
"Load more ${actualMembersCount - members.length} participants"),
leading: Icon(Icons.refresh),
onTap: () => requestMoreMembersAction(context),
),
),
),
);
}
}

135
lib/views/chat_list.dart Normal file
View file

@ -0,0 +1,135 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/dialogs/new_group_dialog.dart';
import 'package:fluffychat/components/dialogs/new_private_chat_dialog.dart';
import 'package:fluffychat/components/list_items/chat_list_item.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/views/settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
class ChatListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AdaptivePageLayout(
primaryPage: FocusPage.FIRST,
firstScaffold: ChatList(),
secondScaffold: Scaffold(
body: Center(
child: Icon(Icons.chat, size: 100, color: Color(0xFF5625BA)),
),
),
);
}
}
class ChatList extends StatefulWidget {
final String activeChat;
const ChatList({this.activeChat, Key key}) : super(key: key);
@override
_ChatListState createState() => _ChatListState();
}
class _ChatListState extends State<ChatList> {
RoomList roomList;
Future<List<Room>> getRooms(BuildContext context) async {
Client client = Matrix.of(context).client;
if (roomList != null) return roomList.rooms;
if (client.prevBatch?.isEmpty ?? true)
await client.connection.onFirstSync.stream.first;
roomList = client.getRoomList(onUpdate: () {
setState(() {});
});
return roomList.rooms;
}
@override
void dispose() {
roomList?.eventSub?.cancel();
roomList?.firstSyncSub?.cancel();
roomList?.roomSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
"Conversations",
),
actions: <Widget>[
IconButton(
icon: Icon(Icons.search),
onPressed: () {},
),
PopupMenuButton(
onSelected: (String choice) {
switch (choice) {
case "settings":
Navigator.of(context).push(
AppRoute.defaultRoute(
context,
SettingsView(),
),
);
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: "settings",
child: Text('Settings'),
),
],
),
],
),
floatingActionButton: SpeedDial(
child: Icon(Icons.add),
backgroundColor: Color(0xFF5625BA),
children: [
SpeedDialChild(
child: Icon(Icons.people_outline),
backgroundColor: Colors.blue,
label: 'Create new group',
labelStyle: TextStyle(fontSize: 18.0),
onTap: () => showDialog(
context: context,
builder: (BuildContext innerContext) => NewGroupDialog(),
),
),
SpeedDialChild(
child: Icon(Icons.chat_bubble_outline),
backgroundColor: Colors.green,
label: 'New private chat',
labelStyle: TextStyle(fontSize: 18.0),
onTap: () => showDialog(
context: context,
builder: (BuildContext innerContext) => NewPrivateChatDialog()),
),
],
),
body: FutureBuilder<List<Room>>(
future: getRooms(context),
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
List<Room> rooms = snapshot.data;
return ListView.builder(
itemCount: rooms.length,
itemBuilder: (BuildContext context, int i) => ChatListItem(
rooms[i],
activeChat: widget.activeChat == rooms[i].id,
),
);
} else
return Center(
child: CircularProgressIndicator(),
);
},
),
);
}
}

View file

@ -0,0 +1,74 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/avatar.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
import 'package:toast/toast.dart';
import 'chat_list.dart';
class InvitationSelection extends StatelessWidget {
final Room room;
const InvitationSelection(this.room, {Key key}) : super(key: key);
Future<List<User>> getContacts(BuildContext context) async {
final Client client = Matrix.of(context).client;
List<User> participants = await room.requestParticipants();
List<User> contacts = [];
Map<String, bool> userMap = {};
for (int i = 0; i < client.roomList.rooms.length; i++) {
List<User> roomUsers = client.roomList.rooms[i].getParticipants();
for (int j = 0; j < roomUsers.length; j++) {
if (userMap[roomUsers[j].id] != true &&
participants.indexWhere((u) => u.id == roomUsers[j].id) == -1)
contacts.add(roomUsers[j]);
userMap[roomUsers[j].id] = true;
}
}
return contacts;
}
void inviteAction(BuildContext context, String id) async {
final success = await Matrix.of(context).tryRequestWithLoadingDialog(
room.invite(id),
);
if (success != false)
Toast.show(
"Contact has been invited to the group.",
context,
duration: Toast.LENGTH_LONG,
);
}
@override
Widget build(BuildContext context) {
final String groupName = room.name?.isEmpty ?? false ? "group" : room.name;
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(activeChat: room.id),
secondScaffold: Scaffold(
appBar: AppBar(
title: Text("Invite contact to $groupName"),
),
body: FutureBuilder<List<User>>(
future: getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData)
return Center(
child: CircularProgressIndicator(),
);
List<User> contacts = snapshot.data;
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (BuildContext context, int i) => ListTile(
leading: Avatar(contacts[i].avatarUrl),
title: Text(contacts[i].calcDisplayname()),
subtitle: Text(contacts[i].id),
onTap: () => inviteAction(context, contacts[i].id),
),
);
},
)),
);
}
}

144
lib/views/login.dart Normal file
View file

@ -0,0 +1,144 @@
import 'dart:math';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:flutter/material.dart';
const String defaultHomeserver = "https://matrix.org";
class LoginPage extends StatefulWidget {
@override
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
final TextEditingController serverController =
TextEditingController(text: "matrix.org");
String usernameError;
String passwordError;
String serverError;
void login(BuildContext context) async {
MatrixState matrix = Matrix.of(context);
if (usernameController.text.isEmpty) {
setState(() => usernameError = "Please enter your username.");
print("Please enter your username.");
} else {
setState(() => usernameError = null);
}
if (passwordController.text.isEmpty) {
setState(() => passwordError = "Please enter your password.");
} else {
setState(() => passwordError = null);
}
serverError = null;
if (usernameController.text.isEmpty || passwordController.text.isEmpty)
return;
String homeserver = serverController.text;
if (homeserver.isEmpty) homeserver = defaultHomeserver;
if (!homeserver.startsWith("https://"))
homeserver = "https://" + homeserver;
try {
matrix.showLoadingDialog(context);
if (!await matrix.client.checkServer(homeserver)) {
setState(() => serverError = "Homeserver is not compatible.");
return matrix.hideLoadingDialog();
}
} catch (exception) {
setState(() => serverError = "Connection attempt failed!");
return matrix.hideLoadingDialog();
}
try {
await matrix.client
.login(usernameController.text, passwordController.text);
} on MatrixException catch (exception) {
setState(() => passwordError = exception.errorMessage);
return matrix.hideLoadingDialog();
} catch (exception) {
setState(() => passwordError = exception.toString());
return matrix.hideLoadingDialog();
}
Matrix.of(context).saveAccount();
matrix.hideLoadingDialog();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: serverController,
decoration: InputDecoration(
icon: Icon(Icons.domain),
hintText: "matrix.org",
errorText: serverError,
errorMaxLines: 1,
prefixText: "https://",
labelText: serverError == null ? "Homeserver" : serverError),
),
),
body: ListView(
padding: EdgeInsets.symmetric(
vertical: 16,
horizontal: max((MediaQuery.of(context).size.width - 600) / 2, 16)),
children: <Widget>[
Image.asset("assets/fluffychat-banner.png"),
TextField(
controller: usernameController,
decoration: InputDecoration(
hintText: "@username:domain",
icon: Icon(Icons.account_box),
errorText: usernameError,
labelText: "Username"),
),
TextField(
controller: passwordController,
obscureText: true,
onSubmitted: (t) => login(context),
decoration: InputDecoration(
icon: Icon(Icons.vpn_key),
hintText: "****",
errorText: passwordError,
labelText: "Password"),
),
SizedBox(height: 20),
Card(
elevation: 7,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
child: Container(
width: 120.0,
height: 50.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50),
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: <Color>[
Colors.blue,
Color(0xFF5625BA),
],
),
),
child: RawMaterialButton(
onPressed: () => login(context),
splashColor: Colors.grey,
child: Text(
"Login",
style: TextStyle(color: Colors.white, fontSize: 20.0),
),
),
),
),
],
),
);
}
}

141
lib/views/settings.dart Normal file
View file

@ -0,0 +1,141 @@
import 'dart:io';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/content_banner.dart';
import 'package:fluffychat/components/matrix.dart';
import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/views/chat_list.dart';
import 'package:fluffychat/views/login.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:toast/toast.dart';
class SettingsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(),
secondScaffold: Settings(),
);
}
}
class Settings extends StatefulWidget {
@override
_SettingsState createState() => _SettingsState();
}
class _SettingsState extends State<Settings> {
Future<dynamic> profileFuture;
dynamic profile;
void logoutAction(BuildContext context) async {
MatrixState matrix = Matrix.of(context);
Navigator.of(context).pushAndRemoveUntil(
AppRoute.defaultRoute(context, LoginPage()), (r) => false);
await matrix.tryRequestWithLoadingDialog(matrix.client.logout());
matrix.clean();
}
void setDisplaynameAction(BuildContext context, String displayname) async {
final MatrixState matrix = Matrix.of(context);
final Map<String, dynamic> success =
await matrix.tryRequestWithLoadingDialog(
matrix.client.connection.jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/profile/${matrix.client.userID}/displayname",
data: {"displayname": displayname},
),
);
if (success != null && success.length == 0) {
Toast.show(
"Displayname has been changed",
context,
duration: Toast.LENGTH_LONG,
);
setState(() {
profileFuture = null;
profile = null;
});
}
}
void setAvatarAction(BuildContext context) async {
final File tempFile = await ImagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: 50,
maxWidth: 1600,
maxHeight: 1600);
if (tempFile == null) return;
final MatrixState matrix = Matrix.of(context);
final Map<String, dynamic> success =
await matrix.tryRequestWithLoadingDialog(
matrix.client.setAvatar(
MatrixFile(
bytes: await tempFile.readAsBytes(),
path: tempFile.path,
),
),
);
if (success != null && success.length == 0) {
Toast.show(
"Avatar has been changed",
context,
duration: Toast.LENGTH_LONG,
);
setState(() {
profileFuture = null;
profile = null;
});
}
}
@override
Widget build(BuildContext context) {
final Client client = Matrix.of(context).client;
profileFuture ??= client.getProfileFromUserId(client.userID);
profileFuture.then((p) => setState(() => profile = p));
return Scaffold(
appBar: AppBar(
title: Text("Settings"),
),
body: ListView(
children: <Widget>[
ContentBanner(
profile?.avatarUrl ?? MxContent(""),
defaultIcon: Icons.account_circle,
loading: profile == null,
),
kIsWeb
? Container()
: ListTile(
title: Text("Upload avatar"),
trailing: Icon(Icons.file_upload),
onTap: () => setAvatarAction(context),
),
ListTile(
trailing: Icon(Icons.edit),
title: TextField(
readOnly: profile == null,
textInputAction: TextInputAction.done,
onSubmitted: (s) => setDisplaynameAction(context, s),
decoration: InputDecoration(
border: InputBorder.none,
labelText: "Edit displayname",
labelStyle: TextStyle(color: Colors.black),
hintText: (profile?.displayname ?? ""),
),
),
),
ListTile(
trailing: Icon(Icons.exit_to_app),
title: Text("Logout"),
onTap: () => logoutAction(context),
),
],
),
);
}
}