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 <info@braid.business>
This commit is contained in:
The one with the braid 2023-11-23 19:29:02 +01:00
parent 9c55800aeb
commit f1a43dfb45
17 changed files with 1056 additions and 842 deletions

View file

@ -1742,6 +1742,7 @@
"type": "text",
"placeholders": {}
},
"dynamicTheme": "Dynamisch",
"theyDontMatch": "Stimmen nicht überein",
"@theyDontMatch": {
"type": "text",

View file

@ -1936,6 +1936,7 @@
"type": "text",
"placeholders": {}
},
"dynamicTheme": "Dynamic",
"theyDontMatch": "They Don't Match",
"@theyDontMatch": {
"type": "text",

View file

@ -1759,6 +1759,7 @@
"type": "text",
"placeholders": {}
},
"dynamicTheme": "Dynamique",
"theyDontMatch": "Elles ne correspondent pas",
"@theyDontMatch": {
"type": "text",

View file

@ -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<ChatPageWithRoom> {
final colorSeedController = ScopedColorSeedController();
Room get room => sendingClient.getRoomById(roomId) ?? widget.room;
late Client sendingClient;
@ -1308,6 +1311,9 @@ class ChatController extends State<ChatPageWithRoom> {
editEvent = null;
});
void onProfileImageAvailable(Color value) =>
colorSeedController.setSeed(value);
@override
Widget build(BuildContext context) => ChatView(this);
}

View file

@ -38,6 +38,7 @@ class ChatAppBarTitle extends StatelessWidget {
),
size: 32,
presenceUserId: room.directChatMatrixID,
onProfileColorCallback: controller.onProfileImageAvailable,
),
),
const SizedBox(width: 12),

View file

@ -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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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,
),
),
],
),
),
);
},
),
);
},
),
),
),
),
),
);
},
);
}
}

View file

@ -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<ChatDetails> {
bool displaySettings = false;
ScopedColorSeedController colorSeedController = ScopedColorSeedController();
void toggleDisplaySettings() =>
setState(() => displaySettings = !displaySettings);
@ -397,6 +400,9 @@ class ChatDetailsController extends State<ChatDetails> {
static const fixedWidth = 360.0;
void onProfileImageAvailable(Color value) =>
colorSeedController.setSeed(value);
@override
Widget build(BuildContext context) => ChatDetailsView(this);
}

View file

@ -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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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),
),
),
),
),
),
);
},
);
},
);

View file

@ -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<ChatList>
});
}
@override
Widget build(BuildContext context) => ChatListView(this);
void _hackyWebRTCFixForWeb() {
ChatList.contextForVoip = context;
}
@ -715,6 +713,12 @@ class ChatListController extends State<ChatList>
Future<void> dehydrate() =>
SettingsSecurityController.dehydrateDevice(context);
void onProfileImageAvailable(Color color) =>
ThemeController.of(context).profileThemeSeed = color;
@override
Widget build(BuildContext context) => ChatListView(this);
}
enum EditBundleAction { addToBundle, removeFromBundle }

View file

@ -220,6 +220,7 @@ class ClientChooserButton extends StatelessWidget {
matrix.client.userID!.localpart,
size: 32,
fontSize: 12,
onProfileColorCallback: controller.onProfileImageAvailable,
),
),
),

View file

@ -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)

View file

@ -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<UserBottomSheet> {
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<UserBottomSheet> {
}
}
void onProfileImageAvailable(Color value) =>
colorSeedController.setSeed(value);
@override
Widget build(BuildContext context) => UserBottomSheetView(this);
}

View file

@ -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),
),
),
],
),
),
);
},
);
}
}

View file

@ -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<Color>? 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<void> _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,
),
),
),

View file

@ -23,6 +23,7 @@ class MxcImage extends StatefulWidget {
final ThumbnailMethod thumbnailMethod;
final Widget Function(BuildContext context)? placeholder;
final String? cacheKey;
final ValueChanged<Uint8List>? 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<MxcImage> {
static final Map<String, Uint8List> _imageDataCache = {};
Uint8List? _imageDataNoCache;
Uint8List? get _imageData {
final cacheKey = widget.cacheKey;
return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey];
@ -90,6 +93,7 @@ class _MxcImageState extends State<MxcImage> {
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<MxcImage> {
throw Exception();
}
final remoteData = response.bodyBytes;
widget.onImageDataCallback?.call(remoteData);
if (!mounted) return;
setState(() {
_imageData = remoteData;
@ -122,8 +126,10 @@ class _MxcImageState extends State<MxcImage> {
);
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<MxcImage> {
}
void _tryLoad(_) async {
if (_imageData != null) return;
final data = _imageData;
if (data != null) {
widget.onImageDataCallback?.call(data);
return;
}
try {
await _load();
} catch (_) {

View file

@ -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<ScopedColorSeedBuilder> createState() => _ScopedColorSeedBuilderState();
}
class _ScopedColorSeedBuilderState extends State<ScopedColorSeedBuilder> {
StreamSubscription<Color?>? _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<Color?>.broadcast();
void setSeed(Color? seed) => _colorStreamController.add(seed);
static Future<Color> imageHelper(Uint8List image) async {
final scheme = await ColorScheme.fromImageProvider(
provider: MemoryImage(image),
);
final color = scheme.primary;
return color;
}
}

View file

@ -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<ThemeBuilder> {
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<ThemeController>(
context,
@ -51,6 +64,7 @@ class ThemeController extends State<ThemeBuilder> {
setState(() {
_themeMode = ThemeMode.values
.singleWhereOrNull((value) => value.name == rawThemeMode);
_primaryColor = rawColor == null ? null : Color(rawColor);
});
}
@ -94,7 +108,7 @@ class ThemeController extends State<ThemeBuilder> {
builder: (light, _) => widget.builder(
context,
themeMode,
primaryColor ?? light?.primary,
primaryColor ?? _profileThemeSeed ?? light?.primary,
),
),
);