diff --git a/android/build.gradle b/android/build.gradle index 58a8c74..713d7f6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/lib/models/message.dart b/lib/models/message.dart index fbc8c58..fa9f7ea 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -4,6 +4,7 @@ import 'dart:developer'; import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/apis/message_api.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:uuid/uuid.dart'; enum MessageType { @@ -43,11 +44,13 @@ class Message implements Comparable { final String senderId; final MessageType type; final String content; + final FormatNode formattedContent; final DateTime sendTime; final MessageState state; 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}) { final typeString = (map["messageType"] as String?) ?? ""; @@ -68,10 +71,21 @@ class Message implements Comparable { Message copy() => copyWith(); - Message copyWith({String? id, String? recipientId, 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, + Message copyWith({ + String? id, + String? recipientId, + 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 ); } diff --git a/lib/models/session.dart b/lib/models/session.dart index d71d2b0..253d017 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,12 +1,16 @@ +import 'package:contacts_plus_plus/string_formatter.dart'; + class Session { final String id; final String name; + final FormatNode formattedName; final List sessionUsers; final String thumbnail; final int maxUsers; final bool hasEnded; final bool isValid; final String description; + final FormatNode formattedDescription; final List tags; final bool headlessHost; final String hostUsername; @@ -15,7 +19,7 @@ class Session { 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.tags, required this.headlessHost, required this.hostUsername, required this.accessLevel, - }); + }) : formattedName = FormatNode.fromText(name), formattedDescription = FormatNode.fromText(description); factory Session.fromMap(Map map) { return Session( diff --git a/lib/string_formatter.dart b/lib/string_formatter.dart new file mode 100644 index 0000000..814e7f9 --- /dev/null +++ b/lib/string_formatter.dart @@ -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 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 = []; + + 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 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 parseTags(String text) { + final startMatches = RegExp(r"<(.+?)>").allMatches(text); + + final spans = []; + + 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 _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(); +} \ No newline at end of file diff --git a/lib/widgets/formatted_text.dart b/lib/widgets/formatted_text.dart new file mode 100644 index 0000000..a081c87 --- /dev/null +++ b/lib/widgets/formatted_text.dart @@ -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, + ); + } + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_asset.dart b/lib/widgets/messages/message_asset.dart index a3e05cc..72c3ebb 100644 --- a/lib/widgets/messages/message_asset.dart +++ b/lib/widgets/messages/message_asset.dart @@ -6,6 +6,8 @@ import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/models/photo_asset.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:flutter/material.dart'; import 'package:full_screen_image/full_screen_image.dart'; @@ -18,7 +20,6 @@ class MessageAsset extends StatelessWidget { final Message message; final DateFormat _dateFormat = DateFormat.Hm(); - @override Widget build(BuildContext context) { final content = jsonDecode(message.content); @@ -26,7 +27,7 @@ class MessageAsset extends StatelessWidget { try { photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList()); } catch (_) {} - + final formattedName = FormatNode.fromText(content["name"]); return Container( constraints: const BoxConstraints(maxWidth: 300), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), @@ -61,7 +62,17 @@ class MessageAsset extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, 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: const EdgeInsets.symmetric(horizontal: 4.0), child: Text( diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index d8dd2f9..d27d7cc 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1,4 +1,5 @@ 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_audio_player.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: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 { MyMessageBubble({required this.message, super.key}); @@ -79,8 +83,8 @@ class MyMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - message.content, + FormattedText( + message.formattedContent, softWrap: true, maxLines: null, style: Theme @@ -148,7 +152,6 @@ class OtherMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - var content = message.content; switch (message.type) { case MessageType.sessionInvite: return Row( @@ -193,7 +196,6 @@ class OtherMessageBubble extends StatelessWidget { ], ); case MessageType.unknown: - rawText: case MessageType.text: return Row( mainAxisSize: MainAxisSize.min, @@ -214,8 +216,8 @@ class OtherMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - content, + FormattedText( + message.formattedContent, softWrap: true, maxLines: null, style: Theme diff --git a/lib/widgets/messages/message_session_invite.dart b/lib/widgets/messages/message_session_invite.dart index a9dafdc..02541e7 100644 --- a/lib/widgets/messages/message_session_invite.dart +++ b/lib/widgets/messages/message_session_invite.dart @@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/message.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/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; @@ -38,7 +39,7 @@ class MessageSessionInvite extends StatelessWidget { Expanded( child: Padding( 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( diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index ad1a3bb..e88c708 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -126,9 +126,9 @@ class _MessagesListState extends State { builder: (context, mClient, _) { final cache = mClient.getUserMessageCache(widget.friend.id); if (cache == null) { - return Column( + return const Column( mainAxisAlignment: MainAxisAlignment.start, - children: const [ + children: [ LinearProgressIndicator() ], ); diff --git a/lib/widgets/messages/messages_session_header.dart b/lib/widgets/messages/messages_session_header.dart index 6ae3b1d..de830d4 100644 --- a/lib/widgets/messages/messages_session_header.dart +++ b/lib/widgets/messages/messages_session_header.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus_plus/auxiliary.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:flutter/material.dart'; @@ -30,8 +31,10 @@ class SessionPopup extends StatelessWidget { Expanded( child: ListView( children: [ - Text(session.name, style: Theme.of(context).textTheme.titleMedium), - Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium), + FormattedText(session.formattedName, style: Theme.of(context).textTheme.titleMedium), + 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(", ")}", style: Theme.of(context).textTheme.labelMedium, softWrap: true, @@ -62,11 +65,11 @@ class SessionPopup extends StatelessWidget { }, ), ), - ) else Expanded( + ) else const Expanded( child: Center( child: Column( mainAxisSize: MainAxisSize.min, - children: const [ + children: [ Icon(Icons.person_remove_alt_1_rounded), Padding( padding: EdgeInsets.all(16.0), @@ -86,11 +89,11 @@ class SessionPopup extends StatelessWidget { placeholder: (context, url) { return const CircularProgressIndicator(); }, - errorWidget: (context, error, what) => Column( + errorWidget: (context, error, what) => const Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, - children: const [ + children: [ Icon(Icons.no_photography), Padding( padding: EdgeInsets.all(16.0), diff --git a/pubspec.lock b/pubspec.lock index cc5249e..ca995ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.1" + color: + dependency: "direct main" + description: + name: color + sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb + url: "https://pub.dev" + source: hosted + version: "3.0.0" crypto: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1de48ae..8bdd4f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.1.1+1 +version: 1.2.0+1 environment: sdk: '>=3.0.0' @@ -55,6 +55,7 @@ dependencies: provider: ^6.0.5 full_screen_image: ^2.0.0 photo_view: ^0.14.0 + color: ^3.0.0 dev_dependencies: flutter_test: