Add basic rendering of formatted text

This commit is contained in:
Nutcake 2023-05-12 18:31:05 +02:00
parent 0c5e354a31
commit cc81d375e0
12 changed files with 338 additions and 26 deletions

View file

@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/apis/message_api.dart'; import 'package:contacts_plus_plus/apis/message_api.dart';
import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
enum MessageType { enum MessageType {
@ -43,11 +44,13 @@ class Message implements Comparable {
final String senderId; final String senderId;
final MessageType type; final MessageType type;
final String content; final String content;
final FormatNode formattedContent;
final DateTime sendTime; final DateTime sendTime;
final MessageState state; final MessageState state;
Message({required this.id, required this.recipientId, required this.senderId, required this.type, Message({required this.id, required this.recipientId, required this.senderId, required this.type,
required this.content, required this.sendTime, this.state=MessageState.local}); required this.content, required this.sendTime, this.state=MessageState.local})
: formattedContent = FormatNode.fromText(content);
factory Message.fromMap(Map map, {MessageState? withState}) { factory Message.fromMap(Map map, {MessageState? withState}) {
final typeString = (map["messageType"] as String?) ?? ""; final typeString = (map["messageType"] as String?) ?? "";
@ -68,10 +71,21 @@ class Message implements Comparable {
Message copy() => copyWith(); Message copy() => copyWith();
Message copyWith({String? id, String? recipientId, String? senderId, MessageType? type, String? content, Message copyWith({
DateTime? sendTime, MessageState? state}) { String? id,
return Message(id: id ?? this.id, recipientId: recipientId ?? this.recipientId, senderId: senderId ?? this.senderId, String? recipientId,
type: type ?? this.type, content: content ?? this.content, sendTime: sendTime ?? this.sendTime, String? senderId,
MessageType? type,
String? content,
DateTime? sendTime,
MessageState? state}) {
return Message(
id: id ?? this.id,
recipientId: recipientId ?? this.recipientId,
senderId: senderId ?? this.senderId,
type: type ?? this.type,
content: content ?? this.content,
sendTime: sendTime ?? this.sendTime,
state: state ?? this.state state: state ?? this.state
); );
} }

View file

@ -1,12 +1,16 @@
import 'package:contacts_plus_plus/string_formatter.dart';
class Session { class Session {
final String id; final String id;
final String name; final String name;
final FormatNode formattedName;
final List<SessionUser> sessionUsers; final List<SessionUser> sessionUsers;
final String thumbnail; final String thumbnail;
final int maxUsers; final int maxUsers;
final bool hasEnded; final bool hasEnded;
final bool isValid; final bool isValid;
final String description; final String description;
final FormatNode formattedDescription;
final List<String> tags; final List<String> tags;
final bool headlessHost; final bool headlessHost;
final String hostUsername; final String hostUsername;
@ -15,7 +19,7 @@ class Session {
Session({required this.id, required this.name, required this.sessionUsers, required this.thumbnail, Session({required this.id, required this.name, required this.sessionUsers, required this.thumbnail,
required this.maxUsers, required this.hasEnded, required this.isValid, required this.description, required this.maxUsers, required this.hasEnded, required this.isValid, required this.description,
required this.tags, required this.headlessHost, required this.hostUsername, required this.accessLevel, required this.tags, required this.headlessHost, required this.hostUsername, required this.accessLevel,
}); }) : formattedName = FormatNode.fromText(name), formattedDescription = FormatNode.fromText(description);
factory Session.fromMap(Map map) { factory Session.fromMap(Map map) {
return Session( return Session(

221
lib/string_formatter.dart Normal file
View file

@ -0,0 +1,221 @@
import 'package:color/color.dart' as cc;
import 'package:flutter/material.dart';
class FormatNode {
String text;
final FormatData format;
final List<FormatNode> children;
FormatNode({required this.text, required this.format, required this.children});
bool get isUnformatted => format.isUnformatted && children.isEmpty;
bool get isEmpty => text.isEmpty && children.isEmpty;
factory FormatNode.unformatted(String? text) {
return FormatNode(text: text ?? "", format: FormatData.unformatted(), children: const []);
}
factory FormatNode.fromText(String? text) {
if (text == null) return FormatNode.unformatted(text);
var tags = FormatTag.parseTags(text);
if (tags.isEmpty) return FormatNode.unformatted(text);
final root = FormatNode(
format: FormatData.unformatted(),
text: text.substring(0, tags.first.startIndex),
children: [],
);
final activeTags = <FormatData>[];
for (int i = 0; i < tags.length; i++) {
final tag = tags[i];
final substr = text.substring(tag.endIndex, (i + 1 < tags.length) ? tags[i + 1].startIndex : null);
if (tag.format.isAdditive) {
activeTags.add(tag.format);
} else {
final idx = activeTags.lastIndexWhere((element) => element.name == tag.format.name);
if (idx != -1) {
activeTags.removeAt(idx);
}
}
if (substr.isNotEmpty) {
root.children.add(
FormatNode.buildFromStyles(activeTags, substr)
);
}
}
return root;
}
TextSpan toTextSpan({required TextStyle baseStyle}) {
final spanTree = TextSpan(
text: text,
style: format.isUnformatted ? baseStyle : format.style(),
children: children.map((e) => e.toTextSpan(baseStyle: baseStyle)).toList()
);
return spanTree;
}
static FormatNode buildFromStyles(List<FormatData> styles, String text) {
if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text);
final root = FormatNode(text: "", format: styles.first, children: []);
var current = root;
for (final style in styles.sublist(1)) {
final next = FormatNode(text: "", format: style, children: []);
current.children.add(next);
current = next;
}
current.text = text;
return root;
}
}
class FormatTag {
final int startIndex;
final int endIndex;
final FormatData format;
const FormatTag({
required this.startIndex,
required this.endIndex,
required this.format,
});
static List<FormatTag> parseTags(String text) {
final startMatches = RegExp(r"<(.+?)>").allMatches(text);
final spans = <FormatTag>[];
for (final startMatch in startMatches) {
final fullTag = startMatch.group(1);
if (fullTag == null) continue;
final tag = FormatData.parse(fullTag);
spans.add(
FormatTag(
startIndex: startMatch.start,
endIndex: startMatch.end,
format: tag,
)
);
}
return spans;
}
}
class FormatAction {
final String Function(String input, String parameter)? transform;
final TextStyle Function(String? parameter, TextStyle baseStyle)? style;
FormatAction({this.transform, this.style});
}
class FormatData {
static Color? tryParseColor(String? text) {
if (text == null) return null;
var color = cc.RgbColor.namedColors[text];
if (color != null) {
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
}
try {
color = cc.HexColor(text);
return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round());
} catch (_) {
return null;
}
}
static final Map<String, FormatAction> _richTextTags = {
"align": FormatAction(),
"alpha": FormatAction(style: (param, baseStyle) {
if (param == null || !param.startsWith("#")) return baseStyle;
final alpha = int.tryParse(param.substring(1), radix: 16);
if (alpha == null) return baseStyle;
return baseStyle.copyWith(color: baseStyle.color?.withAlpha(alpha));
}),
"color": FormatAction(style: (param, baseStyle) {
if (param == null) return baseStyle;
final color = tryParseColor(param);
if (color == null) return baseStyle;
return baseStyle.copyWith(color: color);
}),
"b": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontWeight: FontWeight.bold)),
"br": FormatAction(transform: (text, param) => "\n$text"),
"i": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontStyle: FontStyle.italic)),
"cspace": FormatAction(),
"font": FormatAction(),
"indent": FormatAction(),
"line-height": FormatAction(),
"line-indent": FormatAction(),
"link": FormatAction(),
"lowercase": FormatAction(transform: (input, parameter) => input.toLowerCase(),),
"uppercase": FormatAction(transform: (input, parameter) => input.toUpperCase(),),
"smallcaps": FormatAction(),
"margin": FormatAction(),
"mark": FormatAction(style: (param, baseStyle) {
if (param == null) return baseStyle;
final color = tryParseColor(param);
if (color == null) return baseStyle;
return baseStyle.copyWith(backgroundColor: color);
}),
"mspace": FormatAction(),
"noparse": FormatAction(),
"nobr": FormatAction(),
"page": FormatAction(),
"pos": FormatAction(),
"size": FormatAction(
style: (param, baseStyle) {
if (param == null) return baseStyle;
if (param.endsWith("%")) {
final percentage = int.tryParse(param.replaceAll("%", ""));
if (percentage == null || percentage <= 0) return baseStyle;
final baseSize = baseStyle.fontSize ?? 12;
return baseStyle.copyWith(fontSize: baseSize * (percentage / 100));
} else {
final size = num.tryParse(param);
if (size == null || size <= 0) return baseStyle;
return baseStyle.copyWith(fontSize: size.toDouble());
}
}
),
"space": FormatAction(),
"sprite": FormatAction(),
"s": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.lineThrough)),
"u": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.underline)),
"style": FormatAction(),
"sub": FormatAction(),
"sup": FormatAction(),
"voffset": FormatAction(),
"width": FormatAction(),
};
final String name;
final String parameter;
final bool isAdditive;
const FormatData({required this.name, required this.parameter, required this.isAdditive});
factory FormatData.parse(String text) {
if (text.contains("/")) return FormatData(name: text.replaceAll("/", ""), parameter: "", isAdditive: false);
final sepIdx = text.indexOf("=");
if (sepIdx == -1) {
return FormatData(name: text, parameter: "", isAdditive: true);
} else {
return FormatData(
name: text.substring(0, sepIdx).trim().toLowerCase(),
parameter: text.substring(sepIdx + 1, text.length).trim().toLowerCase(),
isAdditive: true,
);
}
}
factory FormatData.unformatted() => const FormatData(name: "", parameter: "", isAdditive: false);
bool get isUnformatted => name.isEmpty && parameter.isEmpty && !isAdditive;
bool get isValid => _richTextTags.containsKey(name);
String? apply(String? text) => text == null ? null : _richTextTags[name]?.transform?.call(text, parameter);
TextStyle style() => _richTextTags[name]?.style?.call(parameter, const TextStyle()) ?? const TextStyle();
}

View file

@ -0,0 +1,47 @@
import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:flutter/material.dart';
class FormattedText extends StatelessWidget {
const FormattedText(this.formatTree, {
this.style,
this.textAlign,
this.overflow,
this.softWrap,
this.maxLines,
super.key
});
final FormatNode formatTree;
final TextStyle? style;
final TextAlign? textAlign;
final TextOverflow? overflow;
final bool? softWrap;
final int? maxLines;
@override
Widget build(BuildContext context) {
if (formatTree.isUnformatted) {
return Text(
formatTree.text,
style: style,
textAlign: textAlign,
overflow: overflow,
softWrap: softWrap,
maxLines: maxLines,
);
} else {
return RichText(
text: formatTree.toTextSpan(
baseStyle: style ?? Theme
.of(context)
.textTheme
.bodyMedium!
),
textAlign: textAlign ?? TextAlign.start,
overflow: overflow ?? TextOverflow.clip,
softWrap: softWrap ?? true,
maxLines: maxLines,
);
}
}
}

View file

@ -6,6 +6,8 @@ import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/models/photo_asset.dart'; import 'package:contacts_plus_plus/models/photo_asset.dart';
import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:full_screen_image/full_screen_image.dart'; import 'package:full_screen_image/full_screen_image.dart';
@ -18,7 +20,6 @@ class MessageAsset extends StatelessWidget {
final Message message; final Message message;
final DateFormat _dateFormat = DateFormat.Hm(); final DateFormat _dateFormat = DateFormat.Hm();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final content = jsonDecode(message.content); final content = jsonDecode(message.content);
@ -26,7 +27,7 @@ class MessageAsset extends StatelessWidget {
try { try {
photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList()); photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList());
} catch (_) {} } catch (_) {}
final formattedName = FormatNode.fromText(content["name"]);
return Container( return Container(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
@ -61,7 +62,17 @@ class MessageAsset extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Expanded(child: Text("${content["name"]}", maxLines: null, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white60),)), Expanded(
child: FormattedText(
formattedName,
maxLines: null,
style: Theme
.of(context)
.textTheme
.bodySmall
?.copyWith(color: Colors.white60),
),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Text( child: Text(

View file

@ -1,4 +1,5 @@
import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/messages/message_asset.dart'; import 'package:contacts_plus_plus/widgets/messages/message_asset.dart';
import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart'; import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart';
import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart'; import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart';
@ -6,6 +7,9 @@ import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
// The way these classes are laid out is pretty unclean, there's a lot of stuff that's shared between the different
// subwidgets with a lot of room for deduplication. Should probably redo this some day.
class MyMessageBubble extends StatelessWidget { class MyMessageBubble extends StatelessWidget {
MyMessageBubble({required this.message, super.key}); MyMessageBubble({required this.message, super.key});
@ -79,8 +83,8 @@ class MyMessageBubble extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( FormattedText(
message.content, message.formattedContent,
softWrap: true, softWrap: true,
maxLines: null, maxLines: null,
style: Theme style: Theme
@ -148,7 +152,6 @@ class OtherMessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = message.content;
switch (message.type) { switch (message.type) {
case MessageType.sessionInvite: case MessageType.sessionInvite:
return Row( return Row(
@ -193,7 +196,6 @@ class OtherMessageBubble extends StatelessWidget {
], ],
); );
case MessageType.unknown: case MessageType.unknown:
rawText:
case MessageType.text: case MessageType.text:
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -214,8 +216,8 @@ class OtherMessageBubble extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( FormattedText(
content, message.formattedContent,
softWrap: true, softWrap: true,
maxLines: null, maxLines: null,
style: Theme style: Theme

View file

@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart';
@ -38,7 +39,7 @@ class MessageSessionInvite extends StatelessWidget {
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,), child: FormattedText(sessionInfo.formattedName, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,),
), ),
), ),
Padding( Padding(

View file

@ -126,9 +126,9 @@ class _MessagesListState extends State<MessagesList> {
builder: (context, mClient, _) { builder: (context, mClient, _) {
final cache = mClient.getUserMessageCache(widget.friend.id); final cache = mClient.getUserMessageCache(widget.friend.id);
if (cache == null) { if (cache == null) {
return Column( return const Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: const [ children: [
LinearProgressIndicator() LinearProgressIndicator()
], ],
); );

View file

@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/widgets/formatted_text.dart';
import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -30,8 +31,10 @@ class SessionPopup extends StatelessWidget {
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [
Text(session.name, style: Theme.of(context).textTheme.titleMedium), FormattedText(session.formattedName, style: Theme.of(context).textTheme.titleMedium),
Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium), session.formattedDescription.isEmpty
? const Text("No description")
: FormattedText(session.formattedDescription, style: Theme.of(context).textTheme.labelMedium),
Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}", Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}",
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
softWrap: true, softWrap: true,
@ -62,11 +65,11 @@ class SessionPopup extends StatelessWidget {
}, },
), ),
), ),
) else Expanded( ) else const Expanded(
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: const [ children: [
Icon(Icons.person_remove_alt_1_rounded), Icon(Icons.person_remove_alt_1_rounded),
Padding( Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@ -86,11 +89,11 @@ class SessionPopup extends StatelessWidget {
placeholder: (context, url) { placeholder: (context, url) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
}, },
errorWidget: (context, error, what) => Column( errorWidget: (context, error, what) => const Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: const [ children: [
Icon(Icons.no_photography), Icon(Icons.no_photography),
Padding( Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),

View file

@ -81,6 +81,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.1" version: "1.17.1"
color:
dependency: "direct main"
description:
name: color
sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb
url: "https://pub.dev"
source: hosted
version: "3.0.0"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:

View file

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.1+1 version: 1.2.0+1
environment: environment:
sdk: '>=3.0.0' sdk: '>=3.0.0'
@ -55,6 +55,7 @@ dependencies:
provider: ^6.0.5 provider: ^6.0.5
full_screen_image: ^2.0.0 full_screen_image: ^2.0.0
photo_view: ^0.14.0 photo_view: ^0.14.0
color: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: