diff --git a/lib/main.dart b/lib/main.dart index 84a9378..717bd6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ -// import 'dart:io'; +import 'dart:io'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -// import 'package:path/path.dart'; -// import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; diff --git a/lib/src/database/daos/reactions.dao.dart b/lib/src/database/daos/reactions.dao.dart index 93f02a4..e0fa70b 100644 --- a/lib/src/database/daos/reactions.dao.dart +++ b/lib/src/database/daos/reactions.dao.dart @@ -1,12 +1,13 @@ import 'package:drift/drift.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/reactions.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/log.dart'; part 'reactions.dao.g.dart'; -@DriftAccessor(tables: [Reactions]) +@DriftAccessor(tables: [Reactions, Contacts]) class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { // this constructor is required so that the main database can create an instance // of this object. @@ -51,7 +52,36 @@ class ReactionsDao extends DatabaseAccessor with _$ReactionsDaoMixin { .watch(); } - Future insertReaction(ReactionsCompanion reaction) async { - await into(reactions).insert(reaction); + Stream> watchReactionWithContacts( + String messageId, + ) { + final query = (select(reactions)).join( + [leftOuterJoin(contacts, contacts.userId.equalsExp(reactions.senderId))], + )..where(reactions.messageId.equals(messageId)); + + return query + .map((row) => (row.readTable(reactions), row.readTableOrNull(contacts))) + .watch(); + } + + Future updateMyReaction(String messageId, String? emoji) async { + try { + await (delete(reactions) + ..where( + (t) => t.senderId.isNull() & t.messageId.equals(messageId), + )) + .go(); + if (emoji != null) { + await into(reactions).insert( + ReactionsCompanion( + messageId: Value(messageId), + emoji: Value(emoji), + senderId: const Value(null), + ), + ); + } + } catch (e) { + Log.error(e); + } } } diff --git a/lib/src/database/tables/reactions.table.dart b/lib/src/database/tables/reactions.table.dart index 1f29c06..5c7a671 100644 --- a/lib/src/database/tables/reactions.table.dart +++ b/lib/src/database/tables/reactions.table.dart @@ -17,5 +17,5 @@ class Reactions extends Table { DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); @override - Set get primaryKey => {messageId, senderId, createdAt}; + Set get primaryKey => {messageId, senderId, emoji}; } diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index c88339a..98faa9a 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -3332,7 +3332,7 @@ class $ReactionsTable extends Reactions } @override - Set get $primaryKey => {messageId, senderId, createdAt}; + Set get $primaryKey => {messageId, senderId, emoji}; @override Reaction map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 6ca346c..42bfe7e 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -340,5 +340,6 @@ "reportUserTitle": "Melde {username}", "reportUserReason": "Meldegrund", "reportUser": "Benutzer melden", - "newDeviceRegistered": "Du hast dich auf einem anderen Gerät angemeldet. Daher wurdest du hier abgemeldet." + "newDeviceRegistered": "Du hast dich auf einem anderen Gerät angemeldet. Daher wurdest du hier abgemeldet.", + "tabToRemoveEmoji": "Tippen um zu entfernen" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 0e6e0dc..68a6418 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -496,5 +496,6 @@ "reportUserTitle": "Report {username}", "reportUserReason": "Reporting reason", "reportUser": "Report user", - "newDeviceRegistered": "You have logged in on another device. You have therefore been logged out here." + "newDeviceRegistered": "You have logged in on another device. You have therefore been logged out here.", + "tabToRemoveEmoji": "Tab to remove" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 443e715..b1a8873 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2083,6 +2083,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You have logged in on another device. You have therefore been logged out here.'** String get newDeviceRegistered; + + /// No description provided for @tabToRemoveEmoji. + /// + /// In en, this message translates to: + /// **'Tab to remove'** + String get tabToRemoveEmoji; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index da18ff0..9b14157 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1106,4 +1106,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get newDeviceRegistered => 'Du hast dich auf einem anderen Gerät angemeldet. Daher wurdest du hier abgemeldet.'; + + @override + String get tabToRemoveEmoji => 'Tippen um zu entfernen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 32e6e2b..56a5f19 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1100,4 +1100,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get newDeviceRegistered => 'You have logged in on another device. You have therefore been logged out here.'; + + @override + String get tabToRemoveEmoji => 'Tab to remove'; } diff --git a/lib/src/services/api/server_messages/reaction.server_message.dart b/lib/src/services/api/server_messages/reaction.server_message.dart index 892a27d..daf4247 100644 --- a/lib/src/services/api/server_messages/reaction.server_message.dart +++ b/lib/src/services/api/server_messages/reaction.server_message.dart @@ -12,8 +12,8 @@ Future handleReaction( if (reaction.remove) { await twonlyDB.reactionsDao .updateReaction(fromUserId, reaction.targetMessageId, groupId, null); + return; } - return; } if (reaction.hasEmoji()) { await twonlyDB.reactionsDao.updateReaction( diff --git a/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart b/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart new file mode 100644 index 0000000..65513b7 --- /dev/null +++ b/lib/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' + as pb; +import 'package:twonly/src/services/api/messages.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/avatar_icon.component.dart'; + +class AllReactionsView extends StatefulWidget { + const AllReactionsView({required this.message, super.key}); + + final Message message; + + @override + State createState() => _AllReactionsViewState(); +} + +class _AllReactionsViewState extends State { + StreamSubscription>? reactionsSub; + List<(Reaction, Contact?)> reactionsUsers = []; + + @override + void initState() { + initAsync(); + super.initState(); + } + + @override + void dispose() { + reactionsSub?.cancel(); + super.dispose(); + } + + Future initAsync() async { + final stream = twonlyDB.reactionsDao + .watchReactionWithContacts(widget.message.messageId); + + reactionsSub = stream.listen((update) { + setState(() { + reactionsUsers = update; + }); + }); + setState(() {}); + } + + Future removeReaction() async { + await twonlyDB.reactionsDao + .updateMyReaction(widget.message.messageId, null); + await sendCipherTextToGroup( + widget.message.groupId, + pb.EncryptedContent( + reaction: pb.EncryptedContent_Reaction( + targetMessageId: widget.message.messageId, + remove: true, + ), + ), + null, + ); + if (mounted) Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + padding: EdgeInsets.zero, + height: 400, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + color: context.color.surface, + boxShadow: const [ + BoxShadow( + blurRadius: 10.9, + color: Color.fromRGBO(0, 0, 0, 0.1), + ), + ], + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.all(30), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: Colors.grey, + ), + height: 3, + width: 60, + ), + Expanded( + child: ListView( + children: reactionsUsers.map((entry) { + return GestureDetector( + onTap: (entry.$2 != null) ? null : removeReaction, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 5, horizontal: 30), + margin: const EdgeInsets.only(left: 4), + child: Row( + children: [ + AvatarIcon( + contact: entry.$2, + userData: (entry.$2 == null) ? gUser : null, + fontSize: 15, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + (entry.$2 == null) + ? context.lang.you + : getContactDisplayName(entry.$2!), + style: const TextStyle(fontSize: 17), + ), + if (entry.$2 == null) + Text( + context.lang.tabToRemoveEmoji, + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + Text( + entry.$1.emoji, + style: const TextStyle(fontSize: 25), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart index e7a84d5..9da67a7 100644 --- a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -40,6 +40,9 @@ class ChatListEntry extends StatefulWidget { class _ChatListEntryState extends State { MediaFileService? mediaService; + List reactions = []; + StreamSubscription>? reactionsSub; + StreamSubscription? mediaFileSub; @override @@ -51,6 +54,7 @@ class _ChatListEntryState extends State { @override void dispose() { mediaFileSub?.cancel(); + reactionsSub?.cancel(); super.dispose(); } @@ -65,6 +69,14 @@ class _ChatListEntryState extends State { } }); } + final stream = + twonlyDB.reactionsDao.watchReactions(widget.message.messageId); + + reactionsSub = stream.listen((update) { + setState(() { + reactions = update; + }); + }); setState(() {}); } @@ -76,8 +88,14 @@ class _ChatListEntryState extends State { widget.message, widget.prevMessage, widget.nextMessage, + reactions.isNotEmpty, ); + final seen = {}; + var reactionsForWidth = + reactions.where((t) => seen.add(t.emoji)).toList().length; + if (reactionsForWidth > 4) reactionsForWidth = 4; + return Align( alignment: right ? Alignment.centerRight : Alignment.centerLeft, child: Padding( @@ -95,36 +113,46 @@ class _ChatListEntryState extends State { message: widget.message, onResponseTriggered: widget.onResponseTriggered, child: Stack( + // overflow: Overflow.visible, + // clipBehavior: Clip.none, alignment: right ? Alignment.centerRight : Alignment.centerLeft, children: [ - ResponseContainer( - msg: widget.message, - group: widget.group, - mediaService: mediaService, - borderRadius: borderRadius, - scrollToMessage: widget.scrollToMessage, - child: (widget.message.type == MessageType.text) - ? ChatTextEntry( - message: widget.message, - nextMessage: widget.nextMessage, - borderRadius: borderRadius, - ) - : (mediaService == null) - ? null - : ChatMediaEntry( + Column( + children: [ + ResponseContainer( + msg: widget.message, + group: widget.group, + mediaService: mediaService, + borderRadius: borderRadius, + scrollToMessage: widget.scrollToMessage, + child: (widget.message.type == MessageType.text) + ? ChatTextEntry( message: widget.message, - group: widget.group, - mediaService: mediaService!, - galleryItems: widget.galleryItems, - ), + nextMessage: widget.nextMessage, + borderRadius: borderRadius, + minWidth: reactionsForWidth * 43, + ) + : (mediaService == null) + ? null + : ChatMediaEntry( + message: widget.message, + group: widget.group, + mediaService: mediaService!, + galleryItems: widget.galleryItems, + ), + ), + if (reactionsForWidth > 0) + const SizedBox(height: 20, width: 10), + ], ), Positioned( - bottom: 5, + bottom: -20, left: 5, right: 5, child: ReactionRow( message: widget.message, + reactions: reactions, ), ), ], @@ -142,6 +170,7 @@ class _ChatListEntryState extends State { Message message, Message? prevMessage, Message? nextMessage, + bool hasReactions, ) { var bottom = 30.0; var top = 0.0; diff --git a/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart b/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart index 8f95acb..be40794 100644 --- a/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart +++ b/lib/src/views/chats/chat_messages_components/chat_reaction_row.dart @@ -1,73 +1,38 @@ -import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:twonly/globals.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/views/chats/chat_messages_components/bottom_sheets/all_reactions.bottom_sheet.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; -class ReactionRow extends StatefulWidget { +class ReactionRow extends StatelessWidget { const ReactionRow({ + required this.reactions, required this.message, super.key, }); + final List reactions; final Message message; - @override - State createState() => _ReactionRowState(); -} - -class _ReactionRowState extends State { - List reactions = []; - StreamSubscription>? reactionsSub; - - @override - void initState() { - initAsync(); - super.initState(); - } - - @override - void dispose() { - reactionsSub?.cancel(); - super.dispose(); - } - - Future initAsync() async { - final stream = - twonlyDB.reactionsDao.watchReactions(widget.message.messageId); - - reactionsSub = stream.listen((update) { - setState(() { - reactions = update; - }); - }); + Future _showReactionMenu(BuildContext context) async { + // ignore: inference_failure_on_function_invocation + await showModalBottomSheet( + context: context, + backgroundColor: Colors.black, + builder: (BuildContext context) { + return AllReactionsView( + message: message, + ); + }, + ); + // if (layer == null) return; } @override Widget build(BuildContext context) { - final children = []; + final emojis = {}; for (final reaction in reactions) { - // if (content is ReopenedMediaFileContent) { - // if (hasOneReopened) continue; - // hasOneReopened = true; - // children.add( - // Expanded( - // child: Align( - // alignment: Alignment.bottomRight, - // child: Padding( - // padding: const EdgeInsets.only(right: 3), - // child: FaIcon( - // FontAwesomeIcons.repeat, - // size: 12, - // color: isDarkMode(context) ? Colors.white : Colors.black, - // ), - // ), - // ), - // ), - // ); - // } - // only show one reaction - late Widget child; if (EmojiAnimation.animatedIcons.containsKey(reaction.emoji)) { child = SizedBox( @@ -75,24 +40,83 @@ class _ReactionRowState extends State { child: EmojiAnimation(emoji: reaction.emoji), ); } else { - child = Text(reaction.emoji, style: const TextStyle(fontSize: 14)); + child = SizedBox( + height: 18, + child: Center( + child: Text( + reaction.emoji, + style: const TextStyle(fontSize: 18), + strutStyle: const StrutStyle( + forceStrutHeight: true, + height: 1.6, + ), + ), + ), + ); + } + if (emojis.containsKey(reaction.emoji)) { + emojis[reaction.emoji] = + (emojis[reaction.emoji]!.$1, emojis[reaction.emoji]!.$2 + 1); + } else { + emojis[reaction.emoji] = (child, 1); } - children.insert( - 0, - Padding( - padding: const EdgeInsets.only(left: 3), - child: child, - ), - ); } - if (children.isEmpty) return Container(); + if (emojis.isEmpty) return Container(); - return Row( - mainAxisAlignment: widget.message.senderId == null - ? MainAxisAlignment.end - : MainAxisAlignment.end, - children: children, + var emojisToShow = emojis.values.toList() + ..sort((a, b) => b.$2.compareTo(a.$2)); + + if (emojisToShow.length > 4) { + emojisToShow = emojisToShow.slice(0, 3).toList() + ..add( + ( + SizedBox( + height: 18, + child: Transform.translate( + offset: const Offset(0, -3), + child: const FaIcon(FontAwesomeIcons.ellipsis), + ), + ), + 1 + ), + ); + } + + return GestureDetector( + onTap: () => _showReactionMenu(context), + child: Container( + color: Colors.transparent, + padding: const EdgeInsets.only(bottom: 20, top: 5), + child: Row( + mainAxisAlignment: message.senderId == null + ? MainAxisAlignment.start + : MainAxisAlignment.end, + children: emojisToShow.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 5), + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + border: Border.all(), + borderRadius: BorderRadius.circular(12), + color: const Color.fromARGB(255, 74, 74, 74), + ), + child: Row( + children: [ + entry.$1, + if (entry.$2 > 1) + SizedBox( + height: 19, + child: Text( + entry.$2.toString(), + ), + ), + ], + ), + ); + }).toList(), + ), + ), ); } } diff --git a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart index 47fd528..a09450d 100644 --- a/lib/src/views/chats/chat_messages_components/chat_text_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_text_entry.dart @@ -12,12 +12,14 @@ class ChatTextEntry extends StatelessWidget { required this.message, required this.nextMessage, required this.borderRadius, + required this.minWidth, super.key, }); final Message message; final Message? nextMessage; final BorderRadius borderRadius; + final double minWidth; @override Widget build(BuildContext context) { @@ -37,9 +39,13 @@ class ChatTextEntry extends StatelessWidget { final displayTime = !combineTextMessageWithNext(message, nextMessage); + var spacerWidth = minWidth - measureTextWidth(text) - 53; + if (spacerWidth < 0) spacerWidth = 0; + return Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, + minWidth: minWidth, ), padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10), decoration: BoxDecoration( @@ -49,26 +55,32 @@ class ChatTextEntry extends StatelessWidget { ), child: Row( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (measureTextWidth(text) > 270) Expanded( child: BetterText(text: text), ) - else + else ...[ BetterText(text: text), + SizedBox( + width: spacerWidth, + ), + ], if (displayTime) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Text( - friendlyTime(context, message.createdAt), - style: TextStyle( - fontSize: 10, - color: Colors.white.withAlpha(150), + Align( + alignment: AlignmentGeometry.centerRight, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + friendlyTime(context, message.createdAt), + style: TextStyle( + fontSize: 10, + color: Colors.white.withAlpha(150), + ), ), ), - ) + ), ], ), ); diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart index 349f349..7d6f592 100644 --- a/lib/src/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -1,6 +1,5 @@ // ignore_for_file: inference_failure_on_function_invocation -import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -48,12 +47,8 @@ class MessageContextMenu extends StatelessWidget { ) as EmojiLayerData?; if (layer == null) return; - await twonlyDB.reactionsDao.insertReaction( - ReactionsCompanion( - messageId: Value(message.messageId), - emoji: Value(layer.text), - ), - ); + await twonlyDB.reactionsDao + .updateMyReaction(message.messageId, layer.text); await sendCipherTextToGroup( message.groupId, diff --git a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart index 524f8bd..210b6f1 100644 --- a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart +++ b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart @@ -1,9 +1,7 @@ // ignore_for_file: avoid_dynamic_calls -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:twonly/globals.dart'; -import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; @@ -37,12 +35,8 @@ class _EmojiReactionWidgetState extends State { curve: Curves.linearToEaseOut, child: GestureDetector( onTap: () async { - await twonlyDB.reactionsDao.insertReaction( - ReactionsCompanion( - messageId: Value(widget.messageId), - emoji: Value(widget.emoji), - ), - ); + await twonlyDB.reactionsDao + .updateMyReaction(widget.messageId, widget.emoji); await sendCipherTextToGroup( widget.groupId, diff --git a/lib/src/views/components/animate_icon.dart b/lib/src/views/components/animate_icon.dart index 267a400..981c315 100644 --- a/lib/src/views/components/animate_icon.dart +++ b/lib/src/views/components/animate_icon.dart @@ -33,7 +33,7 @@ class EmojiAnimation extends StatelessWidget { '😭': 'loudly-crying.json', '🤯': 'mind-blown.json', '❤️‍🔥': 'red_heart_fire.json', - '😁': 'grinning.json', + //'😁': 'grinning.json', '😆': 'laughing.json', '😅': 'grin-sweat.json', '🤣': 'rofl.json', diff --git a/lib/src/views/components/better_text.dart b/lib/src/views/components/better_text.dart index d05d174..d5f27b2 100644 --- a/lib/src/views/components/better_text.dart +++ b/lib/src/views/components/better_text.dart @@ -63,6 +63,7 @@ class BetterText extends StatelessWidget { children: spans, ), softWrap: true, + textAlign: TextAlign.start, overflow: TextOverflow.visible, style: const TextStyle( color: Colors.white,