Commit 07f47e5d authored by Wilko Manger's avatar Wilko Manger

Use composition instead of inheritance for bubbles

parent d8f341ab
......@@ -23,7 +23,7 @@ import 'package:image/image.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:mime/mime.dart';
import 'package:pattle/src/section/main/chat/event.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:pattle/src/section/main/models/chat_message.dart';
import '../../../matrix.dart';
import '../../../util/room.dart';
......@@ -133,10 +133,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final events = await room.timeline.paginate(page: page);
final chatItems = List<ChatItem>();
final messages = List<ChatMessage>();
// Remember: 'previous' is actually next in time
RoomEvent previousEvent;
RoomEvent event;
for (event in events) {
var shouldIgnore = false;
......@@ -171,34 +169,43 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
continue;
}
// Insert DateHeader if there's a day difference
if (previousEvent != null &&
event != null &&
previousEvent.time.day != event.time.day) {
chatItems.add(DateItem(previousEvent.time));
ChatMessage inReplyTo;
if (event is MessageEvent && event.content.inReplyToId != null) {
final inReplyToEvent = await room.timeline[event.content.inReplyToId];
if (inReplyToEvent != null) {
inReplyTo = ChatMessage(
room,
inReplyToEvent,
isMine: inReplyToEvent.sender == me,
);
}
}
chatItems.add(ChatMessage(room, event));
previousEvent = event;
messages.add(
ChatMessage(
room,
event,
inReplyTo: inReplyTo,
isMine: event.sender == me,
),
);
}
bool endReached = false;
// Add date header above first event in room
if (event is RoomCreationEvent) {
chatItems.add(DateItem(event.time));
endReached = true;
}
if (currentState is ChatLoaded) {
return currentState.copyWith(
items: currentState.items + chatItems,
messages: currentState.messages + messages,
pageCount: page + 1,
endReached: endReached,
);
} else {
return ChatLoaded(
items: chatItems,
messages: messages,
pageCount: page + 1,
endReached: endReached,
);
......
......@@ -19,7 +19,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:pattle/src/section/main/models/chat_message.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
......@@ -35,7 +35,9 @@ class ImagePage extends StatefulWidget {
ImagePage._(this.event);
static Widget withBloc(ChatMessage<ImageMessageEvent> message) {
static Widget withBloc(ChatMessage message) {
assert(message.event is ImageMessageEvent);
return BlocProvider<ImageBloc>(
create: (c) => ImageBloc(Matrix.of(c), message.room),
child: ImagePage._(message.event),
......
......@@ -26,7 +26,6 @@ import 'package:pattle/src/app.dart';
import 'package:pattle/src/resources/localizations.dart';
import 'package:pattle/src/resources/theme.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:pattle/src/section/main/widgets/chat_name.dart';
import 'package:pattle/src/section/main/widgets/error.dart';
import 'package:pattle/src/section/main/widgets/title_with_sub.dart';
......@@ -38,7 +37,8 @@ import '../../../util/url.dart';
import 'bloc.dart';
import 'util/typing_span.dart';
import 'widgets/bubble.dart';
import 'widgets/bubble/message.dart';
import 'widgets/bubble/state.dart';
import 'widgets/date_header.dart';
class ChatPage extends StatefulWidget {
......@@ -263,47 +263,55 @@ class _ChatPageState extends State<ChatPage> {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
if (state is ChatLoaded) {
final me = Matrix.of(context).user;
return ListView.builder(
controller: _scrollController,
reverse: true,
itemCount:
state.endReached ? state.items.length : state.items.length + 1,
padding: EdgeInsets.symmetric(horizontal: 16),
itemCount: state.endReached
? state.messages.length
: state.messages.length + 1,
itemBuilder: (context, index) {
if (index >= state.items.length) {
if (index >= state.messages.length) {
return CircularProgressIndicator();
}
final item = state.items[index];
if (item is ChatMessage) {
final event = item.event;
final isMine = event.sender == me;
var previousItem, nextItem;
// Note: Because the items are reversed in the
// ListView.builder, the 'previous' event is actually the next
// one in the list.
if (index != state.items.length - 1) {
previousItem = state.items[index + 1];
}
if (index != 0) {
nextItem = state.items[index - 1];
}
return Bubble.fromItem(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
) ??
Container();
} else if (item is DateItem) {
return DateHeader(item);
final message = state.messages[index];
final event = message.event;
var previousMessage, nextMessage;
// Note: Because the items are reversed in the
// ListView.builder, the 'previous' event is actually the next
// one in the list.
if (index != state.messages.length - 1) {
previousMessage = state.messages[index + 1];
}
if (index != 0) {
nextMessage = state.messages[index - 1];
}
Widget bubble;
if (event is StateEvent) {
bubble = StateBubble.withContent(message: message);
} else {
bubble = MessageBubble.withContent(
message: message,
previousMessage: previousMessage,
nextMessage: nextMessage,
);
}
// Insert DateHeader if there's a day difference
if (previousMessage != null &&
event != null &&
previousMessage.event.time.day != event.time.day) {
return DateHeader(
date: previousMessage.event.time,
child: bubble,
);
} else {
return Container();
return bubble;
}
},
);
......
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:pattle/src/section/main/models/chat_message.dart';
abstract class ChatState extends Equatable {
@override
......@@ -11,27 +11,27 @@ class ChatLoading extends ChatState {}
class ChatLoaded extends ChatState {
final int pageCount;
final List<ChatItem> items;
final List<ChatMessage> messages;
final endReached;
ChatLoaded({
@required this.items,
@required this.messages,
@required this.pageCount,
this.endReached = true,
});
ChatLoaded copyWith({
@required List<ChatItem> items,
@required List<ChatMessage> messages,
@required int pageCount,
bool endReached = true,
}) {
return ChatLoaded(
items: items ?? this.items,
messages: messages ?? this.messages,
pageCount: pageCount ?? this.pageCount,
endReached: endReached ?? this.endReached,
);
}
@override
List<Object> get props => [items];
List<Object> get props => [messages, pageCount, endReached];
}
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'image_bubble.dart';
import 'item.dart';
import 'redacted_bubble.dart';
import 'state/creation_bubble.dart';
import 'state/member_bubble.dart';
import 'state/topic_bubble.dart';
import 'state/upgrade_bubble.dart';
import 'text_bubble.dart';
abstract class Bubble extends Item {
@override
final ChatMessage item;
final RoomEvent event;
final bool isMine;
// Styling
static const padding = EdgeInsets.all(8);
static const radiusForBorder = Radius.circular(8);
Bubble({
@required this.item,
ChatItem previousItem,
ChatItem nextItem,
@required this.isMine,
}) : event = item.event,
super(
item: item,
previousItem: previousItem,
nextItem: nextItem,
);
factory Bubble.fromItem({
@required ChatMessage item,
ChatItem previousItem,
ChatItem nextItem,
@required bool isMine,
}) {
if (item.event is TextMessageEvent) {
return TextBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is ImageMessageEvent) {
return ImageBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is MemberChangeEvent) {
return MemberBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is RedactedEvent) {
return RedactedBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is RoomCreationEvent) {
return CreationBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is RoomUpgradeEvent) {
return UpgradeBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else if (item.event is TopicChangeEvent) {
return TopicBubble(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
} else {
return null;
}
}
factory Bubble.asReply({
@required Room room,
@required RoomEvent reply,
@required RoomEvent replyTo,
@required bool isMine,
}) {
final item = ChatMessage(room, replyTo);
if (replyTo is TextMessageEvent) {
return TextBubble(
item: item,
isMine: isMine,
reply: reply,
);
} else if (replyTo is ImageMessageEvent) {
return ImageBubble(
item: item,
isMine: isMine,
reply: reply,
);
} else {
return null;
}
}
}
......@@ -15,28 +15,4 @@
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:matrix_sdk/matrix_sdk.dart';
abstract class ChatItem {}
class DateItem implements ChatItem {
final DateTime date;
DateItem(this.date);
}
class ChatMessage<T extends RoomEvent> implements ChatItem {
final Room room;
final T event;
ChatMessage(this.room, this.event);
ChatMessage<E> as<E extends RoomEvent>() {
final e = event;
if (e is E) {
return ChatMessage<E>(room, e);
} else {
return null;
}
}
}
export 'message/message.dart';
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/app.dart';
import '../../../../../../../util/url.dart';
import '../../message.dart';
/// Creates an [ImageContent] widget for a [MessageBubble].
///
/// Must have a [MessageBubble] ancestor.
class ImageContent extends StatefulWidget {
@override
State<StatefulWidget> createState() => _ImageContentState();
}
class _ImageContentState extends State<ImageContent> {
static const double _width = 256;
static const double _minHeight = 72;
static const double _maxHeight = 292;
void _onTap() {
Navigator.pushNamed(
context,
Routes.image,
arguments: MessageBubble.of(context).message,
);
}
@override
Widget build(BuildContext context) {
final info = MessageBubble.of(context);
assert(info.message.event is ImageMessageEvent);
final event = info.message.event as ImageMessageEvent;
final height = (event.content.info?.height ??
0 / (event.content.info?.width ?? 0 / _width))
.clamp(_minHeight, _maxHeight);
return Container(
width: _width,
height: height,
decoration: BoxDecoration(borderRadius: info.borderRadius),
child: Stack(
children: <Widget>[
Positioned.fill(
child: ClipRRect(
borderRadius: info.borderRadius,
child: Hero(
tag: event.id,
child: CachedNetworkImage(
imageUrl: event.content.url.toThumbnailString(context),
fit: BoxFit.cover,
),
),
),
),
if (info.isEndOfGroup) _MessageInfo(),
if (info.message.isMine && info.isStartOfGroup) _Sender(),
Positioned.fill(
child: Clickable(
extraMaterial: true,
onTap: _onTap,
),
),
],
),
);
}
}
class _MessageInfo extends StatelessWidget {
@override
Widget build(BuildContext context) {
final info = MessageBubble.of(context);
var alignment, borderRadius;
if (info.message.isMine) {
alignment = Alignment.bottomRight;
borderRadius = info.borderRadius;
} else {
alignment = Alignment.bottomLeft;
borderRadius = BorderRadius.all(info.borderRadius.bottomLeft);
}
return Align(
alignment: alignment,
child: Padding(
padding: info.contentPadding,
child: Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
color: Color(0x64000000),
),
child: Padding(
padding: EdgeInsets.all(4),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: !info.message.isMine ? Colors.white : null,
),
child: MessageInfo(),
),
),
),
),
);
}
}
class _Sender extends StatelessWidget {
@override
Widget build(BuildContext context) {
final info = MessageBubble.of(context);
return Align(
alignment: Alignment.topLeft,
child: Padding(
padding: info.contentPadding,
child: Container(
decoration: BoxDecoration(
borderRadius: info.borderRadius,
color: Color(0x64000000),
),
child: Padding(
padding: EdgeInsets.all(6),
child: DefaultTextStyle(
style: TextStyle(
color: Colors.white,
),
child: Sender(),
),
),
),
),
);
}
}
// Copyright (C) 2019 Wilko Manger
// Copyright (C) 2019 Mathieu Velten (FLA signed)
//
// This file is part of Pattle.
//
......@@ -17,52 +16,45 @@
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/app.dart';
import 'package:pattle/src/resources/localizations.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:pattle/src/section/main/widgets/redacted.dart';
import '../../../../../util/user.dart';
import '../../../../../../../util/color.dart';
import 'state_bubble.dart';
import '../../message.dart';
class TopicBubble extends StateBubble {
/// Redacted content for a [MessageBubble].
///
/// Must have a [MessageBubble] as ancestor.
class RedactedContent extends StatelessWidget {
@override
final TopicChangeEvent event;
TopicBubble({
@required ChatMessage item,
@required ChatItem previousItem,
@required ChatItem nextItem,
@required bool isMine,
}) : event = item.event,
super(
item: item,
previousItem: previousItem,
nextItem: nextItem,
isMine: isMine,
);
@override
get onTap => (context) {
return Navigator.of(context).pushNamed(
Routes.chatsSettings,
arguments: item.room,
);
};
@override
State<StatefulWidget> createState() => TopicBubbleState();
}
class TopicBubbleState extends StateBubbleState<TopicBubble> {
@protected
@override
List<TextSpan> buildContentSpans(BuildContext context) =>
l(context).changedDescriptionTapToView(
TextSpan(
text: widget.event.sender.displayName,
style: defaultEmphasisTextStyle,
Widget build(BuildContext context) {
final info = MessageBubble.of(context);
return Clickable(
child: Padding(
padding: info.contentPadding,
child: Column(
crossAxisAlignment: info.message.isMine
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: <Widget>[
Sender(),
DefaultTextStyle(
style: TextStyle(
color: themed(
context,
light:
info.message.isMine ? Colors.grey[300] : Colors.grey[700],
dark: info.message.isMine ? Colors.white30 : Colors.white70,
),
),
child: Redacted(event: info.message.event),
),
SizedBox(height: 4),
MessageInfo()
],
),
);
),
);
}
}
</
......@@ -16,109 +16,92 @@
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:flutter/material.dart';
import 'package:future_or_builder/future_or_builder.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/section/main/models/chat_item.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_html/flutter_html.dart';
import '../../../../matrix.dart';
import '../../../../util/color.dart';
import '../../../../util/user.dart';
import '../../../../../../../util/color.dart';
import '../../../../../../../util/user.dart';
import 'bubble.dart';
import 'message_bubble.dart';
class TextBubble extends MessageBubble {
static const replyMargin = 8.0;
static const replyLeftPadding = 12.0;
import '../../message.dart';
/// Content for a [MessageBubble] with a [TextMessageEvent].
///
/// Must have a [MessageBubble] ancestor.
class TextContent extends StatelessWidget {
static const _replyMargin = 8.0;
static const _replyLeftPadding = 12.0;
@override
final TextMessageEvent event;
Widget build(BuildContext context) {
final info = MessageBubble.of(context);
TextBubble({
@required ChatMessage item,
ChatItem previousItem,
ChatItem nextItem,
@required bool isMine,
RoomEvent reply,