From f1a43dfb453efe2fa62a601ec60e09a5f97c198e Mon Sep 17 00:00:00 2001 From: The one with the braid Date: Thu, 23 Nov 2023 19:29:02 +0100 Subject: [PATCH] feat: implement scoped dynamic colors - implement scoped theme builder with seed controller - apply scoped seed to room view, room details and profile preview - in case dynamic theme is selected, use user profile picture as global color seed - adjust localication from system color to dynamic color Further reading : https://m3.material.io/styles/color/dynamic/content-based-source Signed-off-by: The one with the braid --- assets/l10n/intl_de.arb | 1 + assets/l10n/intl_en.arb | 1 + assets/l10n/intl_fr.arb | 1 + lib/pages/chat/chat.dart | 6 + lib/pages/chat/chat_app_bar_title.dart | 1 + lib/pages/chat/chat_view.dart | 467 ++++++----- lib/pages/chat_details/chat_details.dart | 6 + lib/pages/chat_details/chat_details_view.dart | 793 +++++++++--------- lib/pages/chat_list/chat_list.dart | 10 +- .../chat_list/client_chooser_button.dart | 1 + .../settings_style/settings_style_view.dart | 2 +- .../user_bottom_sheet/user_bottom_sheet.dart | 6 + .../user_bottom_sheet_view.dart | 461 +++++----- lib/widgets/avatar.dart | 19 + lib/widgets/mxc_image.dart | 16 +- lib/widgets/scoped_color_seed_builder.dart | 81 ++ lib/widgets/theme_builder.dart | 26 +- 17 files changed, 1056 insertions(+), 842 deletions(-) create mode 100644 lib/widgets/scoped_color_seed_builder.dart diff --git a/assets/l10n/intl_de.arb b/assets/l10n/intl_de.arb index e472d80e..f3807fca 100644 --- a/assets/l10n/intl_de.arb +++ b/assets/l10n/intl_de.arb @@ -1742,6 +1742,7 @@ "type": "text", "placeholders": {} }, + "dynamicTheme": "Dynamisch", "theyDontMatch": "Stimmen nicht überein", "@theyDontMatch": { "type": "text", diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index b8498fb4..0a3a731b 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -1936,6 +1936,7 @@ "type": "text", "placeholders": {} }, + "dynamicTheme": "Dynamic", "theyDontMatch": "They Don't Match", "@theyDontMatch": { "type": "text", diff --git a/assets/l10n/intl_fr.arb b/assets/l10n/intl_fr.arb index 6ca325c8..68c34431 100644 --- a/assets/l10n/intl_fr.arb +++ b/assets/l10n/intl_fr.arb @@ -1759,6 +1759,7 @@ "type": "text", "placeholders": {} }, + "dynamicTheme": "Dynamique", "theyDontMatch": "Elles ne correspondent pas", "@theyDontMatch": { "type": "text", diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 44965d5c..654dca7a 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -32,6 +32,7 @@ import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; import '../../utils/account_bundles.dart'; import '../../utils/localized_exception_extension.dart'; import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; @@ -104,6 +105,8 @@ class ChatPageWithRoom extends StatefulWidget { } class ChatController extends State { + final colorSeedController = ScopedColorSeedController(); + Room get room => sendingClient.getRoomById(roomId) ?? widget.room; late Client sendingClient; @@ -1308,6 +1311,9 @@ class ChatController extends State { editEvent = null; }); + void onProfileImageAvailable(Color value) => + colorSeedController.setSeed(value); + @override Widget build(BuildContext context) => ChatView(this); } diff --git a/lib/pages/chat/chat_app_bar_title.dart b/lib/pages/chat/chat_app_bar_title.dart index 1cb84b48..4df79cbc 100644 --- a/lib/pages/chat/chat_app_bar_title.dart +++ b/lib/pages/chat/chat_app_bar_title.dart @@ -38,6 +38,7 @@ class ChatAppBarTitle extends StatelessWidget { ), size: 32, presenceUserId: room.directChatMatrixID, + onProfileColorCallback: controller.onProfileImageAvailable, ), ), const SizedBox(width: 12), diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index c8ef099a..41ed9738 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -19,6 +19,7 @@ import 'package:fluffychat/pages/chat/tombstone_display.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; import 'package:fluffychat/widgets/unread_rooms_badge.dart'; import '../../utils/stream_extension.dart'; import 'chat_emoji_picker.dart'; @@ -136,238 +137,258 @@ class ChatView extends StatelessWidget { final bottomSheetPadding = FluffyThemes.isColumnMode(context) ? 16.0 : 8.0; final scrollUpBannerEventId = controller.scrollUpBannerEventId; - return PopScope( - canPop: controller.selectedEvents.isEmpty && !controller.showEmojiPicker, - onPopInvoked: (pop) async { - if (pop) return; - if (controller.selectedEvents.isNotEmpty) { - controller.clearSelectedEvents(); - } else if (controller.showEmojiPicker) { - controller.emojiPickerAction(); - } - }, - child: GestureDetector( - onTapDown: (_) => controller.setReadMarker(), - behavior: HitTestBehavior.opaque, - child: StreamBuilder( - stream: controller.room.onUpdate.stream - .rateLimit(const Duration(seconds: 1)), - builder: (context, snapshot) => FutureBuilder( - future: controller.loadTimelineFuture, - builder: (BuildContext context, snapshot) { - return Scaffold( - appBar: AppBar( - actionsIconTheme: IconThemeData( - color: controller.selectedEvents.isEmpty - ? null - : Theme.of(context).colorScheme.primary, - ), - leading: controller.selectMode - ? IconButton( - icon: const Icon(Icons.close), - onPressed: controller.clearSelectedEvents, - tooltip: L10n.of(context)!.close, - color: Theme.of(context).colorScheme.primary, - ) - : UnreadRoomsBadge( - filter: (r) => r.id != controller.roomId, - badgePosition: BadgePosition.topEnd(end: 8, top: 4), - child: const Center(child: BackButton()), - ), - titleSpacing: 0, - title: ChatAppBarTitle(controller), - actions: _appBarActions(context), - ), - floatingActionButton: controller.showScrollDownButton && - controller.selectedEvents.isEmpty - ? Padding( - padding: const EdgeInsets.only(bottom: 56.0), - child: FloatingActionButton( - onPressed: controller.scrollDown, - heroTag: null, - mini: true, - child: const Icon(Icons.arrow_downward_outlined), - ), - ) - : null, - body: DropTarget( - onDragDone: controller.onDragDone, - onDragEntered: controller.onDragEntered, - onDragExited: controller.onDragExited, - child: Stack( - children: [ - if (Matrix.of(context).wallpaper != null) - Image.file( - Matrix.of(context).wallpaper!, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - filterQuality: FilterQuality.medium, - ), - SafeArea( - child: Column( - children: [ - TombstoneDisplay(controller), - if (scrollUpBannerEventId != null) - Material( - color: Theme.of(context) - .colorScheme - .surfaceVariant, - shape: Border( - bottom: BorderSide( - width: 1, - color: Theme.of(context).dividerColor, - ), - ), - child: ListTile( - leading: IconButton( + return ScopedColorSeedBuilder( + controller: controller.colorSeedController, + builder: (context, color) { + return PopScope( + canPop: + controller.selectedEvents.isEmpty && !controller.showEmojiPicker, + onPopInvoked: (pop) async { + if (pop) return; + if (controller.selectedEvents.isNotEmpty) { + controller.clearSelectedEvents(); + } else if (controller.showEmojiPicker) { + controller.emojiPickerAction(); + } + }, + child: GestureDetector( + onTapDown: (_) => controller.setReadMarker(), + behavior: HitTestBehavior.opaque, + child: StreamBuilder( + stream: controller.room.onUpdate.stream + .rateLimit(const Duration(seconds: 1)), + builder: (context, snapshot) => FutureBuilder( + future: controller.loadTimelineFuture, + builder: (BuildContext context, snapshot) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + actionsIconTheme: IconThemeData( + color: controller.selectedEvents.isEmpty + ? null + : Theme.of(context).colorScheme.primary, + ), + leading: controller.selectMode + ? IconButton( + icon: const Icon(Icons.close), + onPressed: controller.clearSelectedEvents, + tooltip: L10n.of(context)!.close, + color: Theme.of(context).colorScheme.primary, + ) + : UnreadRoomsBadge( + filter: (r) => r.id != controller.roomId, + badgePosition: + BadgePosition.topEnd(end: 8, top: 4), + child: const Center(child: BackButton()), + ), + titleSpacing: 0, + title: ChatAppBarTitle(controller), + actions: _appBarActions(context), + ), + floatingActionButton: controller.showScrollDownButton && + controller.selectedEvents.isEmpty + ? Padding( + padding: const EdgeInsets.only(bottom: 56.0), + child: FloatingActionButton( + onPressed: controller.scrollDown, + heroTag: null, + mini: true, + child: const Icon(Icons.arrow_downward_outlined), + ), + ) + : null, + body: DropTarget( + onDragDone: controller.onDragDone, + onDragEntered: controller.onDragEntered, + onDragExited: controller.onDragExited, + child: Stack( + children: [ + if (Matrix.of(context).wallpaper != null) + Image.file( + Matrix.of(context).wallpaper!, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + SafeArea( + child: Column( + children: [ + TombstoneDisplay(controller), + if (scrollUpBannerEventId != null) + Material( color: Theme.of(context) .colorScheme - .onSurfaceVariant, - icon: const Icon(Icons.close), - tooltip: L10n.of(context)!.close, - onPressed: () { - controller.discardScrollUpBannerEventId(); - controller.setReadMarker(); - }, + .surfaceVariant, + shape: Border( + bottom: BorderSide( + width: 1, + color: Theme.of(context).dividerColor, + ), + ), + child: ListTile( + leading: IconButton( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + icon: const Icon(Icons.close), + tooltip: L10n.of(context)!.close, + onPressed: () { + controller + .discardScrollUpBannerEventId(); + controller.setReadMarker(); + }, + ), + title: Text( + L10n.of(context)!.jumpToLastReadMessage, + ), + contentPadding: + const EdgeInsets.only(left: 8), + trailing: TextButton( + onPressed: () { + controller.scrollToEventId( + scrollUpBannerEventId, + ); + controller + .discardScrollUpBannerEventId(); + }, + child: Text(L10n.of(context)!.jump), + ), + ), ), - title: Text( - L10n.of(context)!.jumpToLastReadMessage, - ), - contentPadding: - const EdgeInsets.only(left: 8), - trailing: TextButton( - onPressed: () { - controller.scrollToEventId( - scrollUpBannerEventId, - ); - controller.discardScrollUpBannerEventId(); - }, - child: Text(L10n.of(context)!.jump), - ), - ), - ), - PinnedEvents(controller), - Expanded( - child: GestureDetector( - onTap: controller.clearSingleSelectedEvent, - child: Builder( - builder: (context) { - if (controller.timeline == null) { - return const Center( - child: - CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } + PinnedEvents(controller), + Expanded( + child: GestureDetector( + onTap: controller.clearSingleSelectedEvent, + child: Builder( + builder: (context) { + if (controller.timeline == null) { + return const Center( + child: CircularProgressIndicator + .adaptive( + strokeWidth: 2, + ), + ); + } - return ChatEventList( - controller: controller, - ); - }, + return ChatEventList( + controller: controller, + ); + }, + ), + ), ), + if (controller.room.canSendDefaultMessages && + controller.room.membership == + Membership.join) + Container( + margin: EdgeInsets.only( + bottom: bottomSheetPadding, + left: bottomSheetPadding, + right: bottomSheetPadding, + ), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 2.5, + ), + alignment: Alignment.center, + child: Material( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular( + AppConfig.borderRadius, + ), + bottomRight: Radius.circular( + AppConfig.borderRadius, + ), + ), + elevation: 4, + shadowColor: Colors.black.withAlpha(64), + clipBehavior: Clip.hardEdge, + color: Theme.of(context).brightness == + Brightness.light + ? Colors.white + : Colors.black, + child: controller + .room.isAbandonedDMRoom == + true + ? Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + style: TextButton.styleFrom( + padding: + const EdgeInsets.all( + 16, + ), + foregroundColor: + Theme.of(context) + .colorScheme + .error, + ), + icon: const Icon( + Icons.archive_outlined, + ), + onPressed: + controller.leaveChat, + label: Text( + L10n.of(context)!.leave, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: + const EdgeInsets.all( + 16, + ), + ), + icon: const Icon( + Icons.forum_outlined, + ), + onPressed: + controller.recreateChat, + label: Text( + L10n.of(context)! + .reopenChat, + ), + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ConnectionStatusHeader(), + ReactionsPicker(controller), + ReplyDisplay(controller), + ChatInputRow(controller), + ChatEmojiPicker(controller), + ], + ), + ), + ), + ], + ), + ), + if (controller.dragging) + Container( + color: Theme.of(context) + .scaffoldBackgroundColor + .withOpacity(0.9), + alignment: Alignment.center, + child: const Icon( + Icons.upload_outlined, + size: 100, ), ), - if (controller.room.canSendDefaultMessages && - controller.room.membership == Membership.join) - Container( - margin: EdgeInsets.only( - bottom: bottomSheetPadding, - left: bottomSheetPadding, - right: bottomSheetPadding, - ), - constraints: const BoxConstraints( - maxWidth: FluffyThemes.columnWidth * 2.5, - ), - alignment: Alignment.center, - child: Material( - borderRadius: const BorderRadius.only( - bottomLeft: - Radius.circular(AppConfig.borderRadius), - bottomRight: - Radius.circular(AppConfig.borderRadius), - ), - elevation: 4, - shadowColor: Colors.black.withAlpha(64), - clipBehavior: Clip.hardEdge, - color: Theme.of(context).brightness == - Brightness.light - ? Colors.white - : Colors.black, - child: controller.room.isAbandonedDMRoom == - true - ? Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - TextButton.icon( - style: TextButton.styleFrom( - padding: - const EdgeInsets.all(16), - foregroundColor: - Theme.of(context) - .colorScheme - .error, - ), - icon: const Icon( - Icons.archive_outlined, - ), - onPressed: controller.leaveChat, - label: Text( - L10n.of(context)!.leave, - ), - ), - TextButton.icon( - style: TextButton.styleFrom( - padding: - const EdgeInsets.all(16), - ), - icon: const Icon( - Icons.forum_outlined, - ), - onPressed: - controller.recreateChat, - label: Text( - L10n.of(context)!.reopenChat, - ), - ), - ], - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - const ConnectionStatusHeader(), - ReactionsPicker(controller), - ReplyDisplay(controller), - ChatInputRow(controller), - ChatEmojiPicker(controller), - ], - ), - ), - ), - ], - ), + ], ), - if (controller.dragging) - Container( - color: Theme.of(context) - .scaffoldBackgroundColor - .withOpacity(0.9), - alignment: Alignment.center, - child: const Icon( - Icons.upload_outlined, - size: 100, - ), - ), - ], - ), - ), - ); - }, + ), + ); + }, + ), + ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index d15eb9d7..5f7d9ae1 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; enum AliasActions { copy, delete, setCanonical } @@ -35,6 +36,8 @@ class ChatDetails extends StatefulWidget { class ChatDetailsController extends State { bool displaySettings = false; + ScopedColorSeedController colorSeedController = ScopedColorSeedController(); + void toggleDisplaySettings() => setState(() => displaySettings = !displaySettings); @@ -397,6 +400,9 @@ class ChatDetailsController extends State { static const fixedWidth = 360.0; + void onProfileImageAvailable(Color value) => + colorSeedController.setSeed(value); + @override Widget build(BuildContext context) => ChatDetailsView(this); } diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index 1dc92366..0060a03f 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; import '../../utils/url_launcher.dart'; class ChatDetailsView extends StatelessWidget { @@ -37,399 +38,429 @@ class ChatDetailsView extends StatelessWidget { final isEmbedded = GoRouterState.of(context).fullPath == '/rooms/:roomid'; - return StreamBuilder( - stream: room.onUpdate.stream, - builder: (context, snapshot) { - var members = room.getParticipants().toList() - ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); - members = members.take(10).toList(); - final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + - (room.summary.mJoinedMemberCount ?? 0); - final canRequestMoreMembers = members.length < actualMembersCount; - final iconColor = Theme.of(context).textTheme.bodyLarge!.color; - final displayname = room.getLocalizedDisplayname( - MatrixLocals(L10n.of(context)!), - ); - return Scaffold( - appBar: isEmbedded - ? null - : AppBar( - leading: const Center(child: BackButton()), - elevation: Theme.of(context).appBarTheme.elevation, - actions: [ - if (room.canonicalAlias.isNotEmpty) - IconButton( - tooltip: L10n.of(context)!.share, - icon: Icon(Icons.adaptive.share_outlined), - onPressed: () => FluffyShare.share( - AppConfig.inviteLinkPrefix + room.canonicalAlias, - context, - ), - ), - ChatSettingsPopupMenu(room, false), - ], - title: Text(L10n.of(context)!.chatDetails), - backgroundColor: - Theme.of(context).appBarTheme.backgroundColor, - ), - body: MaxWidthBody( - child: ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: members.length + 1 + (canRequestMoreMembers ? 1 : 0), - itemBuilder: (BuildContext context, int i) => i == 0 - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(32.0), - child: Stack( - children: [ - Material( - elevation: Theme.of(context) + return ScopedColorSeedBuilder( + controller: controller.colorSeedController, + builder: (context, color) { + return StreamBuilder( + stream: room.onUpdate.stream, + builder: (context, snapshot) { + var members = room.getParticipants().toList() + ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); + members = members.take(10).toList(); + final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + + (room.summary.mJoinedMemberCount ?? 0); + final canRequestMoreMembers = members.length < actualMembersCount; + final iconColor = Theme.of(context).textTheme.bodyLarge!.color; + final displayname = room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ); + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: isEmbedded + ? null + : AppBar( + leading: const Center(child: BackButton()), + elevation: Theme.of(context).appBarTheme.elevation, + actions: [ + if (room.canonicalAlias.isNotEmpty) + IconButton( + tooltip: L10n.of(context)!.share, + icon: Icon(Icons.adaptive.share_outlined), + onPressed: () => FluffyShare.share( + AppConfig.inviteLinkPrefix + room.canonicalAlias, + context, + ), + ), + ChatSettingsPopupMenu(room, false), + ], + title: Text(L10n.of(context)!.chatDetails), + backgroundColor: + Theme.of(context).appBarTheme.backgroundColor, + ), + body: MaxWidthBody( + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: + members.length + 1 + (canRequestMoreMembers ? 1 : 0), + itemBuilder: (BuildContext context, int i) => i == 0 + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(32.0), + child: Stack( + children: [ + Material( + elevation: Theme.of(context) + .appBarTheme + .scrolledUnderElevation ?? + 4, + shadowColor: Theme.of(context) .appBarTheme - .scrolledUnderElevation ?? - 4, - shadowColor: Theme.of(context) - .appBarTheme - .shadowColor, - shape: RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context).dividerColor, - ), - borderRadius: BorderRadius.circular( - Avatar.defaultSize * 2.5, - ), - ), - child: Hero( - tag: isEmbedded - ? 'embedded_content_banner' - : 'content_banner', - child: Avatar( - mxContent: room.avatar, - name: displayname, - size: Avatar.defaultSize * 2.5, - fontSize: 18 * 2.5, - ), - ), - ), - if (!room.isDirectChat && - room.canChangeStateEvent( - EventTypes.RoomAvatar, - )) - Positioned( - bottom: 0, - right: 0, - child: FloatingActionButton.small( - onPressed: controller.setAvatarAction, - heroTag: null, - child: const Icon( - Icons.camera_alt_outlined, + .shadowColor, + shape: RoundedRectangleBorder( + side: BorderSide( + color: + Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular( + Avatar.defaultSize * 2.5, + ), + ), + child: Hero( + tag: isEmbedded + ? 'embedded_content_banner' + : 'content_banner', + child: Avatar( + mxContent: room.avatar, + name: displayname, + size: Avatar.defaultSize * 2.5, + fontSize: 18 * 2.5, + onProfileColorCallback: controller + .onProfileImageAvailable, + ), ), ), - ), - ], - ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextButton.icon( - onPressed: () => room.isDirectChat - ? null - : room.canChangeStateEvent( - EventTypes.RoomName, - ) - ? controller.setDisplaynameAction() - : FluffyShare.share( - displayname, - context, - copyOnly: true, - ), - icon: Icon( - room.isDirectChat - ? Icons.chat_bubble_outline - : room.canChangeStateEvent( - EventTypes.RoomName, - ) - ? Icons.edit_outlined - : Icons.copy_outlined, - size: 16, - ), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context) - .colorScheme - .onBackground, - ), - label: Text( - room.isDirectChat - ? L10n.of(context)!.directChat - : displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // style: const TextStyle(fontSize: 18), - ), - ), - TextButton.icon( - onPressed: () => room.isDirectChat - ? null - : context.push( - '/rooms/${controller.roomId}/details/members', + if (!room.isDirectChat && + room.canChangeStateEvent( + EventTypes.RoomAvatar, + )) + Positioned( + bottom: 0, + right: 0, + child: FloatingActionButton.small( + onPressed: + controller.setAvatarAction, + heroTag: null, + child: const Icon( + Icons.camera_alt_outlined, + ), ), - icon: const Icon( - Icons.group_outlined, - size: 14, - ), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context) - .colorScheme - .secondary, - ), - label: Text( - L10n.of(context)!.countParticipants( - actualMembersCount, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - // style: const TextStyle(fontSize: 12), - ), + ), + ], ), - ], + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + TextButton.icon( + onPressed: () => room.isDirectChat + ? null + : room.canChangeStateEvent( + EventTypes.RoomName, + ) + ? controller + .setDisplaynameAction() + : FluffyShare.share( + displayname, + context, + copyOnly: true, + ), + icon: Icon( + room.isDirectChat + ? Icons.chat_bubble_outline + : room.canChangeStateEvent( + EventTypes.RoomName, + ) + ? Icons.edit_outlined + : Icons.copy_outlined, + size: 16, + ), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context) + .colorScheme + .onBackground, + ), + label: Text( + room.isDirectChat + ? L10n.of(context)!.directChat + : displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // style: const TextStyle(fontSize: 18), + ), + ), + TextButton.icon( + onPressed: () => room.isDirectChat + ? null + : context.push( + '/rooms/${controller.roomId}/details/members', + ), + icon: const Icon( + Icons.group_outlined, + size: 14, + ), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context) + .colorScheme + .secondary, + ), + label: Text( + L10n.of(context)!.countParticipants( + actualMembersCount, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + // style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + if (!room.canChangeStateEvent(EventTypes.RoomTopic)) + ListTile( + title: Text( + L10n.of(context)!.chatDescription, + style: TextStyle( + color: + Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + ) + else + Padding( + padding: const EdgeInsets.all(16.0), + child: OutlinedButton.icon( + onPressed: controller.setTopicAction, + label: Text( + L10n.of(context)!.setChatDescription, + ), + icon: const Icon(Icons.edit_outlined), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: SelectableLinkify( + text: room.topic.isEmpty + ? L10n.of(context)!.noChatDescriptionYet + : room.topic, + options: const LinkifyOptions(humanize: false), + linkStyle: + const TextStyle(color: Colors.blueAccent), + style: TextStyle( + fontSize: 14, + fontStyle: room.topic.isEmpty + ? FontStyle.italic + : FontStyle.normal, + color: Theme.of(context) + .textTheme + .bodyMedium! + .color, + decorationColor: Theme.of(context) + .textTheme + .bodyMedium! + .color, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), ), ), + const SizedBox(height: 16), + Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + if (room.joinRules == JoinRules.public) + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon(Icons.link_outlined), + ), + trailing: + const Icon(Icons.chevron_right_outlined), + onTap: controller.editAliases, + title: Text(L10n.of(context)!.editRoomAliases), + subtitle: Text( + (room.canonicalAlias.isNotEmpty) + ? room.canonicalAlias + : L10n.of(context)!.none, + ), + ), + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon( + Icons.insert_emoticon_outlined, + ), + ), + title: Text(L10n.of(context)!.emoteSettings), + subtitle: Text(L10n.of(context)!.setCustomEmotes), + onTap: controller.goToEmoteSettings, + trailing: + const Icon(Icons.chevron_right_outlined), + ), + if (!room.isDirectChat) + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon(Icons.shield_outlined), + ), + title: Text( + L10n.of(context)!.whoIsAllowedToJoinThisGroup, + ), + trailing: room.canChangeJoinRules + ? const Icon(Icons.chevron_right_outlined) + : null, + subtitle: Text( + room.joinRules?.getLocalizedString( + MatrixLocals(L10n.of(context)!), + ) ?? + L10n.of(context)!.none, + ), + onTap: room.canChangeJoinRules + ? controller.setJoinRules + : null, + ), + if (!room.isDirectChat) + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon(Icons.visibility_outlined), + ), + trailing: room.canChangeHistoryVisibility + ? const Icon(Icons.chevron_right_outlined) + : null, + title: Text( + L10n.of(context)!.visibilityOfTheChatHistory, + ), + subtitle: Text( + room.historyVisibility?.getLocalizedString( + MatrixLocals(L10n.of(context)!), + ) ?? + L10n.of(context)!.none, + ), + onTap: room.canChangeHistoryVisibility + ? controller.setHistoryVisibility + : null, + ), + if (room.joinRules == JoinRules.public) + ListTile( + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon( + Icons.person_add_alt_1_outlined, + ), + ), + trailing: room.canChangeGuestAccess + ? const Icon(Icons.chevron_right_outlined) + : null, + title: Text( + L10n.of(context)!.areGuestsAllowedToJoin, + ), + subtitle: Text( + room.guestAccess.getLocalizedString( + MatrixLocals(L10n.of(context)!), + ), + ), + onTap: room.canChangeGuestAccess + ? controller.setGuestAccess + : null, + ), + if (!room.isDirectChat) + ListTile( + title: Text(L10n.of(context)!.chatPermissions), + subtitle: Text( + L10n.of(context)!.whoCanPerformWhichAction, + ), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon( + Icons.edit_attributes_outlined, + ), + ), + trailing: + const Icon(Icons.chevron_right_outlined), + onTap: () => context.push( + '/rooms/${room.id}/details/permissions', + ), + ), + Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), + ListTile( + title: Text( + L10n.of(context)!.countParticipants( + actualMembersCount.toString(), + ), + style: TextStyle( + color: + Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + ), + if (!room.isDirectChat && room.canInvite) + ListTile( + title: Text(L10n.of(context)!.inviteContact), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).primaryColor, + foregroundColor: Colors.white, + radius: Avatar.defaultSize / 2, + child: const Icon(Icons.add_outlined), + ), + trailing: + const Icon(Icons.chevron_right_outlined), + onTap: () => + context.go('/rooms/${room.id}/invite'), + ), ], - ), - Divider( - height: 1, - color: Theme.of(context).dividerColor, - ), - if (!room.canChangeStateEvent(EventTypes.RoomTopic)) - ListTile( - title: Text( - L10n.of(context)!.chatDescription, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, + ) + : i < members.length + 1 + ? ParticipantListItem(members[i - 1]) + : ListTile( + title: Text( + L10n.of(context)!.loadCountMoreParticipants( + (actualMembersCount - members.length) + .toString(), + ), ), - ), - ) - else - Padding( - padding: const EdgeInsets.all(16.0), - child: OutlinedButton.icon( - onPressed: controller.setTopicAction, - label: Text(L10n.of(context)!.setChatDescription), - icon: const Icon(Icons.edit_outlined), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: SelectableLinkify( - text: room.topic.isEmpty - ? L10n.of(context)!.noChatDescriptionYet - : room.topic, - options: const LinkifyOptions(humanize: false), - linkStyle: - const TextStyle(color: Colors.blueAccent), - style: TextStyle( - fontSize: 14, - fontStyle: room.topic.isEmpty - ? FontStyle.italic - : FontStyle.normal, - color: - Theme.of(context).textTheme.bodyMedium!.color, - decorationColor: - Theme.of(context).textTheme.bodyMedium!.color, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - ), - const SizedBox(height: 16), - Divider( - height: 1, - color: Theme.of(context).dividerColor, - ), - if (room.joinRules == JoinRules.public) - ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon(Icons.link_outlined), - ), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: controller.editAliases, - title: Text(L10n.of(context)!.editRoomAliases), - subtitle: Text( - (room.canonicalAlias.isNotEmpty) - ? room.canonicalAlias - : L10n.of(context)!.none, - ), - ), - ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.insert_emoticon_outlined, - ), - ), - title: Text(L10n.of(context)!.emoteSettings), - subtitle: Text(L10n.of(context)!.setCustomEmotes), - onTap: controller.goToEmoteSettings, - trailing: const Icon(Icons.chevron_right_outlined), - ), - if (!room.isDirectChat) - ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon(Icons.shield_outlined), - ), - title: Text( - L10n.of(context)!.whoIsAllowedToJoinThisGroup, - ), - trailing: room.canChangeJoinRules - ? const Icon(Icons.chevron_right_outlined) - : null, - subtitle: Text( - room.joinRules?.getLocalizedString( - MatrixLocals(L10n.of(context)!), - ) ?? - L10n.of(context)!.none, - ), - onTap: room.canChangeJoinRules - ? controller.setJoinRules - : null, - ), - if (!room.isDirectChat) - ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon(Icons.visibility_outlined), - ), - trailing: room.canChangeHistoryVisibility - ? const Icon(Icons.chevron_right_outlined) - : null, - title: Text( - L10n.of(context)!.visibilityOfTheChatHistory, - ), - subtitle: Text( - room.historyVisibility?.getLocalizedString( - MatrixLocals(L10n.of(context)!), - ) ?? - L10n.of(context)!.none, - ), - onTap: room.canChangeHistoryVisibility - ? controller.setHistoryVisibility - : null, - ), - if (room.joinRules == JoinRules.public) - ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.person_add_alt_1_outlined, + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + child: const Icon( + Icons.group_outlined, + color: Colors.grey, + ), ), - ), - trailing: room.canChangeGuestAccess - ? const Icon(Icons.chevron_right_outlined) - : null, - title: Text( - L10n.of(context)!.areGuestsAllowedToJoin, - ), - subtitle: Text( - room.guestAccess.getLocalizedString( - MatrixLocals(L10n.of(context)!), + onTap: () => context.push( + '/rooms/${controller.roomId!}/details/members', ), + trailing: + const Icon(Icons.chevron_right_outlined), ), - onTap: room.canChangeGuestAccess - ? controller.setGuestAccess - : null, - ), - if (!room.isDirectChat) - ListTile( - title: Text(L10n.of(context)!.chatPermissions), - subtitle: Text( - L10n.of(context)!.whoCanPerformWhichAction, - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.edit_attributes_outlined, - ), - ), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: () => context - .push('/rooms/${room.id}/details/permissions'), - ), - Divider( - height: 1, - color: Theme.of(context).dividerColor, - ), - ListTile( - title: Text( - L10n.of(context)!.countParticipants( - actualMembersCount.toString(), - ), - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - ), - if (!room.isDirectChat && room.canInvite) - ListTile( - title: Text(L10n.of(context)!.inviteContact), - leading: CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - radius: Avatar.defaultSize / 2, - child: const Icon(Icons.add_outlined), - ), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: () => context.go('/rooms/${room.id}/invite'), - ), - ], - ) - : i < members.length + 1 - ? ParticipantListItem(members[i - 1]) - : ListTile( - title: Text( - L10n.of(context)!.loadCountMoreParticipants( - (actualMembersCount - members.length).toString(), - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - child: const Icon( - Icons.group_outlined, - color: Colors.grey, - ), - ), - onTap: () => context.push( - '/rooms/${controller.roomId!}/details/members', - ), - trailing: const Icon(Icons.chevron_right_outlined), - ), - ), - ), + ), + ), + ); + }, ); }, ); diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 8828081b..3c205144 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -21,6 +21,7 @@ import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/theme_builder.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; import '../../utils/url_launcher.dart'; @@ -700,9 +701,6 @@ class ChatListController extends State }); } - @override - Widget build(BuildContext context) => ChatListView(this); - void _hackyWebRTCFixForWeb() { ChatList.contextForVoip = context; } @@ -715,6 +713,12 @@ class ChatListController extends State Future dehydrate() => SettingsSecurityController.dehydrateDevice(context); + + void onProfileImageAvailable(Color color) => + ThemeController.of(context).profileThemeSeed = color; + + @override + Widget build(BuildContext context) => ChatListView(this); } enum EditBundleAction { addToBundle, removeFromBundle } diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index f2394bb6..ecb3c400 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -220,6 +220,7 @@ class ClientChooserButton extends StatelessWidget { matrix.client.userID!.localpart, size: 32, fontSize: 12, + onProfileColorCallback: controller.onProfileImageAvailable, ), ), ), diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index 9104912e..c9d86881 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -89,7 +89,7 @@ class SettingsStyleView extends StatelessWidget { ), ), Text( - L10n.of(context)!.systemTheme, + L10n.of(context)!.dynamicTheme, textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context) diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet.dart index 9d87f559..4383e251 100644 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet.dart +++ b/lib/pages/user_bottom_sheet/user_bottom_sheet.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/widgets/permission_slider_dialog.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; import '../../widgets/matrix.dart'; import 'user_bottom_sheet_view.dart'; @@ -87,6 +88,8 @@ class UserBottomSheet extends StatefulWidget { } class UserBottomSheetController extends State { + ScopedColorSeedController colorSeedController = ScopedColorSeedController(); + void participantAction(UserBottomSheetAction action) async { final user = widget.user; final userId = user?.id ?? widget.profile?.userId; @@ -243,6 +246,9 @@ class UserBottomSheetController extends State { } } + void onProfileImageAvailable(Color value) => + colorSeedController.setSeed(value); + @override Widget build(BuildContext context) => UserBottomSheetView(this); } diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart index bdb1d945..cee9115a 100644 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart +++ b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/presence_builder.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; import '../../widgets/matrix.dart'; import 'user_bottom_sheet.dart'; @@ -26,241 +27,251 @@ class UserBottomSheetView extends StatelessWidget { final client = Matrix.of(controller.widget.outerContext).client; final profileSearchError = controller.widget.profileSearchError; - return SafeArea( - child: Scaffold( - appBar: AppBar( - leading: CloseButton( - onPressed: Navigator.of(context, rootNavigator: false).pop, - ), - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(displayname), - PresenceBuilder( - userId: userId, - client: client, - builder: (context, presence) { - if (presence == null || - (presence.presence == PresenceType.offline && - presence.lastActiveTimestamp == null)) { - return const SizedBox.shrink(); - } - final dotColor = presence.presence.isOnline - ? Colors.green - : presence.presence.isUnavailable - ? Colors.red - : Colors.grey; + return ScopedColorSeedBuilder( + controller: controller.colorSeedController, + builder: (context, color) { + return SafeArea( + child: Scaffold( + backgroundColor: Theme.of(context).colorScheme.background, + appBar: AppBar( + leading: CloseButton( + onPressed: Navigator.of(context, rootNavigator: false).pop, + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayname), + PresenceBuilder( + userId: userId, + client: client, + builder: (context, presence) { + if (presence == null || + (presence.presence == PresenceType.offline && + presence.lastActiveTimestamp == null)) { + return const SizedBox.shrink(); + } - final lastActiveTimestamp = presence.lastActiveTimestamp; + final dotColor = presence.presence.isOnline + ? Colors.green + : presence.presence.isUnavailable + ? Colors.red + : Colors.grey; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: dotColor, - borderRadius: BorderRadius.circular(16), - ), - ), - if (presence.currentlyActive == true) - Text( - L10n.of(context)!.currentlyActive, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ) - else if (lastActiveTimestamp != null) - Text( - L10n.of(context)!.lastActiveAgo( - lastActiveTimestamp.localizedTimeShort(context), + final lastActiveTimestamp = presence.lastActiveTimestamp; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: dotColor, + borderRadius: BorderRadius.circular(16), + ), ), - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - }, + if (presence.currentlyActive == true) + Text( + L10n.of(context)!.currentlyActive, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ) + else if (lastActiveTimestamp != null) + Text( + L10n.of(context)!.lastActiveAgo( + lastActiveTimestamp.localizedTimeShort(context), + ), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ], ), - ], - ), - actions: [ - if (userId != client.userID && - !client.ignoredUsers.contains(userId)) - Padding( - padding: const EdgeInsets.all(8.0), - child: OutlinedButton.icon( - label: Text( - L10n.of(context)!.ignore, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + actions: [ + if (userId != client.userID && + !client.ignoredUsers.contains(userId)) + Padding( + padding: const EdgeInsets.all(8.0), + child: OutlinedButton.icon( + label: Text( + L10n.of(context)!.ignore, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + icon: Icon( + Icons.shield_outlined, + color: Theme.of(context).colorScheme.error, + ), + onPressed: () => controller + .participantAction(UserBottomSheetAction.ignore), ), ), - icon: Icon( - Icons.shield_outlined, - color: Theme.of(context).colorScheme.error, - ), - onPressed: () => controller - .participantAction(UserBottomSheetAction.ignore), - ), - ), - ], - ), - body: ListView( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Material( - elevation: - Theme.of(context).appBarTheme.scrolledUnderElevation ?? - 4, - shadowColor: Theme.of(context).appBarTheme.shadowColor, - shape: RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context).dividerColor, - ), - borderRadius: BorderRadius.circular( - Avatar.defaultSize * 2.5, - ), - ), - child: Avatar( - mxContent: avatarUrl, - name: displayname, - size: Avatar.defaultSize * 2.5, - fontSize: 18 * 2.5, - ), - ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextButton.icon( - onPressed: () => FluffyShare.share( - 'https://matrix.to/#/$userId', - context, - ), - icon: Icon( - Icons.adaptive.share_outlined, - size: 16, - ), - style: TextButton.styleFrom( - foregroundColor: - Theme.of(context).colorScheme.onBackground, - ), - label: Text( - displayname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // style: const TextStyle(fontSize: 18), - ), - ), - TextButton.icon( - onPressed: () => FluffyShare.share( - userId, - context, - copyOnly: true, - ), - icon: const Icon( - Icons.copy_outlined, - size: 14, - ), - style: TextButton.styleFrom( - foregroundColor: - Theme.of(context).colorScheme.secondary, - ), - label: Text( - userId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - // style: const TextStyle(fontSize: 12), - ), - ), - ], - ), - ), ], ), - if (userId != client.userID) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, + body: ListView( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Material( + elevation: Theme.of(context) + .appBarTheme + .scrolledUnderElevation ?? + 4, + shadowColor: Theme.of(context).appBarTheme.shadowColor, + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular( + Avatar.defaultSize * 2.5, + ), + ), + child: Avatar( + mxContent: avatarUrl, + name: displayname, + size: Avatar.defaultSize * 2.5, + fontSize: 18 * 2.5, + onProfileColorCallback: + controller.onProfileImageAvailable, + ), + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + onPressed: () => FluffyShare.share( + 'https://matrix.to/#/$userId', + context, + ), + icon: Icon( + Icons.adaptive.share_outlined, + size: 16, + ), + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.onBackground, + ), + label: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // style: const TextStyle(fontSize: 18), + ), + ), + TextButton.icon( + onPressed: () => FluffyShare.share( + userId, + context, + copyOnly: true, + ), + icon: const Icon( + Icons.copy_outlined, + size: 14, + ), + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.secondary, + ), + label: Text( + userId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + // style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ], ), - child: ElevatedButton.icon( - onPressed: () => controller - .participantAction(UserBottomSheetAction.message), - icon: const Icon(Icons.forum_outlined), - label: Text(L10n.of(context)!.sendAMessage), - ), - ), - if (controller.widget.onMention != null) - ListTile( - leading: const Icon(Icons.alternate_email_outlined), - title: Text(L10n.of(context)!.mention), - onTap: () => - controller.participantAction(UserBottomSheetAction.mention), - ), - if (user != null && user.canChangePowerLevel) - ListTile( - title: Text(L10n.of(context)!.setPermissionsLevel), - leading: const Icon(Icons.edit_attributes_outlined), - onTap: () => controller - .participantAction(UserBottomSheetAction.permission), - ), - if (user != null && user.canKick) - ListTile( - title: Text(L10n.of(context)!.kickFromChat), - leading: const Icon(Icons.exit_to_app_outlined), - onTap: () => - controller.participantAction(UserBottomSheetAction.kick), - ), - if (user != null && - user.canBan && - user.membership != Membership.ban) - ListTile( - title: Text(L10n.of(context)!.banFromChat), - leading: const Icon(Icons.warning_sharp), - onTap: () => - controller.participantAction(UserBottomSheetAction.ban), - ) - else if (user != null && - user.canBan && - user.membership == Membership.ban) - ListTile( - title: Text(L10n.of(context)!.unbanFromChat), - leading: const Icon(Icons.warning_outlined), - onTap: () => - controller.participantAction(UserBottomSheetAction.unban), - ), - if (user != null && user.id != client.userID) - ListTile( - textColor: Theme.of(context).colorScheme.onErrorContainer, - iconColor: Theme.of(context).colorScheme.onErrorContainer, - title: Text(L10n.of(context)!.reportUser), - leading: const Icon(Icons.report_outlined), - onTap: () => - controller.participantAction(UserBottomSheetAction.report), - ), - if (profileSearchError != null) - ListTile( - leading: const Icon( - Icons.warning_outlined, - color: Colors.orange, - ), - subtitle: Text( - L10n.of(context)!.profileNotFound, - style: const TextStyle(color: Colors.orange), - ), - ), - ], - ), - ), + if (userId != client.userID) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: ElevatedButton.icon( + onPressed: () => controller + .participantAction(UserBottomSheetAction.message), + icon: const Icon(Icons.forum_outlined), + label: Text(L10n.of(context)!.sendAMessage), + ), + ), + if (controller.widget.onMention != null) + ListTile( + leading: const Icon(Icons.alternate_email_outlined), + title: Text(L10n.of(context)!.mention), + onTap: () => controller + .participantAction(UserBottomSheetAction.mention), + ), + if (user != null && user.canChangePowerLevel) + ListTile( + title: Text(L10n.of(context)!.setPermissionsLevel), + leading: const Icon(Icons.edit_attributes_outlined), + onTap: () => controller + .participantAction(UserBottomSheetAction.permission), + ), + if (user != null && user.canKick) + ListTile( + title: Text(L10n.of(context)!.kickFromChat), + leading: const Icon(Icons.exit_to_app_outlined), + onTap: () => controller + .participantAction(UserBottomSheetAction.kick), + ), + if (user != null && + user.canBan && + user.membership != Membership.ban) + ListTile( + title: Text(L10n.of(context)!.banFromChat), + leading: const Icon(Icons.warning_sharp), + onTap: () => + controller.participantAction(UserBottomSheetAction.ban), + ) + else if (user != null && + user.canBan && + user.membership == Membership.ban) + ListTile( + title: Text(L10n.of(context)!.unbanFromChat), + leading: const Icon(Icons.warning_outlined), + onTap: () => controller + .participantAction(UserBottomSheetAction.unban), + ), + if (user != null && user.id != client.userID) + ListTile( + textColor: Theme.of(context).colorScheme.onErrorContainer, + iconColor: Theme.of(context).colorScheme.onErrorContainer, + title: Text(L10n.of(context)!.reportUser), + leading: const Icon(Icons.report_outlined), + onTap: () => controller + .participantAction(UserBottomSheetAction.report), + ), + if (profileSearchError != null) + ListTile( + leading: const Icon( + Icons.warning_outlined, + color: Colors.orange, + ), + subtitle: Text( + L10n.of(context)!.profileNotFound, + style: const TextStyle(color: Colors.orange), + ), + ), + ], + ), + ), + ); + }, ); } } diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 25c3df15..88e749f8 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; @@ -5,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import 'package:fluffychat/widgets/presence_builder.dart'; +import 'package:fluffychat/widgets/scoped_color_seed_builder.dart'; class Avatar extends StatelessWidget { final Uri? mxContent; @@ -16,6 +19,7 @@ class Avatar extends StatelessWidget { final double fontSize; final String? presenceUserId; final Color? presenceBackgroundColor; + final ValueChanged? onProfileColorCallback; const Avatar({ this.mxContent, @@ -26,9 +30,22 @@ class Avatar extends StatelessWidget { this.fontSize = 18, this.presenceUserId, this.presenceBackgroundColor, + this.onProfileColorCallback, super.key, }); + Future _handleAvatarImageData(Uint8List data) async { + final color = await ScopedColorSeedController.imageHelper(data); + onProfileColorCallback?.call(color); + } + + void _handleNoAvatarImageColor(Color color) { + final hsvColor = HSVColor.fromColor(color); + // dim the color since [String.lightColorAvatar] is quite intense + final dimmed = hsvColor.withSaturation(.25); + onProfileColorCallback?.call(dimmed.toColor()); + } + @override Widget build(BuildContext context) { var fallbackLetters = '@'; @@ -56,6 +73,7 @@ class Avatar extends StatelessWidget { final presenceUserId = this.presenceUserId; final color = noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor; + if (noPic && color != null) _handleNoAvatarImageColor(color); final container = Stack( children: [ ClipRRect( @@ -74,6 +92,7 @@ class Avatar extends StatelessWidget { height: size, placeholder: (_) => textWidget, cacheKey: mxContent.toString(), + onImageDataCallback: _handleAvatarImageData, ), ), ), diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index 0aedd1e4..b91b0d14 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -23,6 +23,7 @@ class MxcImage extends StatefulWidget { final ThumbnailMethod thumbnailMethod; final Widget Function(BuildContext context)? placeholder; final String? cacheKey; + final ValueChanged? onImageDataCallback; const MxcImage({ this.uri, @@ -38,6 +39,7 @@ class MxcImage extends StatefulWidget { this.animationCurve = FluffyThemes.animationCurve, this.thumbnailMethod = ThumbnailMethod.scale, this.cacheKey, + this.onImageDataCallback, super.key, }); @@ -48,6 +50,7 @@ class MxcImage extends StatefulWidget { class _MxcImageState extends State { static final Map _imageDataCache = {}; Uint8List? _imageDataNoCache; + Uint8List? get _imageData { final cacheKey = widget.cacheKey; return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey]; @@ -90,6 +93,7 @@ class _MxcImageState extends State { if (_isCached == null) { final cachedData = await client.database?.getFile(storeKey); if (cachedData != null) { + widget.onImageDataCallback?.call(cachedData); if (!mounted) return; setState(() { _imageData = cachedData; @@ -108,7 +112,7 @@ class _MxcImageState extends State { throw Exception(); } final remoteData = response.bodyBytes; - + widget.onImageDataCallback?.call(remoteData); if (!mounted) return; setState(() { _imageData = remoteData; @@ -122,8 +126,10 @@ class _MxcImageState extends State { ); if (data.detectFileType is MatrixImageFile) { if (!mounted) return; + final bytes = data.bytes; + widget.onImageDataCallback?.call(bytes); setState(() { - _imageData = data.bytes; + _imageData = bytes; }); return; } @@ -131,7 +137,11 @@ class _MxcImageState extends State { } void _tryLoad(_) async { - if (_imageData != null) return; + final data = _imageData; + if (data != null) { + widget.onImageDataCallback?.call(data); + return; + } try { await _load(); } catch (_) { diff --git a/lib/widgets/scoped_color_seed_builder.dart b/lib/widgets/scoped_color_seed_builder.dart new file mode 100644 index 00000000..9493c0a6 --- /dev/null +++ b/lib/widgets/scoped_color_seed_builder.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/widgets/theme_builder.dart'; + +typedef ColorSeedBuilder = Widget Function(BuildContext context, Color? color); + +class ScopedColorSeedBuilder extends StatefulWidget { + final ScopedColorSeedController controller; + final ColorSeedBuilder builder; + + const ScopedColorSeedBuilder({ + super.key, + required this.controller, + required this.builder, + }); + + @override + State createState() => _ScopedColorSeedBuilderState(); +} + +class _ScopedColorSeedBuilderState extends State { + StreamSubscription? _colorSchemeListener; + Color? _color; + + @override + void initState() { + _colorSchemeListener = + widget.controller._colorStreamController.stream.listen(_setColor); + super.initState(); + } + + void _setColor(Color? seed) { + if (seed != _color) setState(() => _color = seed); + } + + @override + Widget build(BuildContext context) { + final fluffyThemeMode = ThemeController.of(context); + + final color = _color; + // if a custom primary color is defined or no custom seed set, + // no need to adjust theme + if (color == null || fluffyThemeMode.primaryColor != null) { + return widget.builder.call(context, color); + } + + final theme = Theme.of(context); + + return Theme( + // build the proper FluffyChat theme with the given seed + data: FluffyThemes.buildTheme(context, theme.brightness, color), + child: Builder( + builder: (context) => widget.builder.call(context, color), + ), + ); + } + + @override + void dispose() { + _colorSchemeListener?.cancel(); + super.dispose(); + } +} + +class ScopedColorSeedController { + final _colorStreamController = StreamController.broadcast(); + + void setSeed(Color? seed) => _colorStreamController.add(seed); + + static Future imageHelper(Uint8List image) async { + final scheme = await ColorScheme.fromImageProvider( + provider: MemoryImage(image), + ); + final color = scheme.primary; + return color; + } +} diff --git a/lib/widgets/theme_builder.dart b/lib/widgets/theme_builder.dart index b35a0b44..5a6361d0 100644 --- a/lib/widgets/theme_builder.dart +++ b/lib/widgets/theme_builder.dart @@ -5,12 +5,14 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +typedef FluffyThemeBuilder = Widget Function( + BuildContext context, + ThemeMode themeMode, + Color?, +); + class ThemeBuilder extends StatefulWidget { - final Widget Function( - BuildContext context, - ThemeMode themeMode, - Color? primaryColor, - ) builder; + final FluffyThemeBuilder builder; final String themeModeSettingsKey; final String primaryColorSettingsKey; @@ -31,10 +33,21 @@ class ThemeController extends State { ThemeMode? _themeMode; Color? _primaryColor; + /// caching if ever set based on the profile pic + Color? _profileThemeSeed; + ThemeMode get themeMode => _themeMode ?? ThemeMode.system; Color? get primaryColor => _primaryColor; + /// Sets the primaryColor at runtime + /// This won't store it but should rather be used for temporary theme changes + /// E.g. used for the profile picture based theme + /// + /// In case a custom theme is selected by the user, this call is ignored + + set profileThemeSeed(Color? color) => _profileThemeSeed = color; + static ThemeController of(BuildContext context) => Provider.of( context, @@ -51,6 +64,7 @@ class ThemeController extends State { setState(() { _themeMode = ThemeMode.values .singleWhereOrNull((value) => value.name == rawThemeMode); + _primaryColor = rawColor == null ? null : Color(rawColor); }); } @@ -94,7 +108,7 @@ class ThemeController extends State { builder: (light, _) => widget.builder( context, themeMode, - primaryColor ?? light?.primary, + primaryColor ?? _profileThemeSeed ?? light?.primary, ), ), );