lots of stuff

This commit is contained in:
Henry Hiles 2025-11-14 15:50:38 -05:00
commit ba9e99a951
No known key found for this signature in database
19 changed files with 608 additions and 360 deletions

View file

@ -0,0 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/client_controller.dart";
class AvatarController extends AsyncNotifier<Uri> {
final String mxc;
AvatarController(this.mxc);
@override
Future<Uri> build() async => Uri.parse(mxc).getThumbnailUri(
await ref.watch(ClientController.provider.future),
width: 24,
height: 24,
);
static final provider = AsyncNotifierProvider.family
.autoDispose<AvatarController, Uri, String>(AvatarController.new);
}

View file

@ -1,5 +1,4 @@
import "dart:io"; import "dart:io";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -29,6 +28,8 @@ class ClientController extends AsyncNotifier<Client> {
if (client.accessToken == null) { if (client.accessToken == null) {
await client.login( await client.login(
LoginType.mLoginPassword, LoginType.mLoginPassword,
initialDeviceDisplayName: "Nexus Client",
deviceId: "temp", // TODO
identifier: AuthenticationUserIdentifier(user: "quadradical"), identifier: AuthenticationUserIdentifier(user: "quadradical"),
password: File("./password.txt").readAsStringSync(), password: File("./password.txt").readAsStringSync(),
); );

View file

@ -0,0 +1,20 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class MembersController extends AsyncNotifier<List<MatrixEvent>> {
final Room room;
MembersController(this.room);
@override
Future<List<MatrixEvent>> build() async =>
(await room.client.getMembersByRoom(
room.id,
notMembership: Membership.leave,
)) ??
[];
static final provider =
AsyncNotifierProvider.family<MembersController, List<MatrixEvent>, Room>(
MembersController.new,
);
}

View file

@ -3,22 +3,26 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
import "package:nexus/controllers/timeline_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
RoomChatController(this.room);
final Room room; final Room room;
RoomChatController(this.room);
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final timeline = await room.getTimeline(); final timeline = await ref.watch(TimelineController.provider(room).future);
room.client.onTimelineEvent.stream.listen((event) async {
if (event.roomId != room.id) return; ref.onDispose(
final message = await event.toMessage(); room.client.onTimelineEvent.stream.listen((event) async {
if (message != null) { if (event.roomId != room.id) return;
await insertMessage(message); final message = await event.toMessage();
} if (message != null) {
}); await insertMessage(message);
}
}).cancel,
);
return InMemoryChatController( return InMemoryChatController(
messages: (await Future.wait( messages: (await Future.wait(
@ -29,15 +33,22 @@ class RoomChatController extends AsyncNotifier<ChatController> {
Future<void> insertMessage(Message message) async { Future<void> insertMessage(Message message) async {
final controller = await future; final controller = await future;
final oldMessage = controller.messages.firstWhereOrNull( final oldMessage = message.metadata?["txnId"] == null
(element) => element.metadata?["txnId"] == message.metadata?["txnId"], ? null
); : controller.messages.firstWhereOrNull(
(element) =>
element.metadata?["txnId"] == message.metadata?["txnId"],
);
return oldMessage == null return oldMessage == null
? controller.insertMessage(message) ? controller.insertMessage(message)
: controller.updateMessage(oldMessage, message); : controller.updateMessage(oldMessage, message);
} }
Future<void> loadOlder() async {
await ref.watch(TimelineController.provider(room).notifier).prev();
}
Future<void> updateMessage(Message message, Message newMessage) async { Future<void> updateMessage(Message message, Message newMessage) async {
final controller = await future; final controller = await future;
return controller.updateMessage(message, newMessage); return controller.updateMessage(message, newMessage);
@ -55,6 +66,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
id: id, id: id,
name: user.displayname, name: user.displayname,
imageSource: (await user.avatarUrl?.getThumbnailUri( imageSource: (await user.avatarUrl?.getThumbnailUri(
// TODO: Fix use of account avatar not room avatar
room.client, room.client,
width: 24, width: 24,
height: 24, height: 24,

View file

@ -30,24 +30,27 @@ class SpacesController extends AsyncNotifier<List<Space>> {
return [ return [
Space( Space(
client: client,
title: "Home", title: "Home",
children: topLevelRooms, children: topLevelRooms,
avatar: Icon(Icons.home), icon: Icon(Icons.home),
fake: true, fake: true,
), ),
Space( Space(
client: client,
title: "Direct Messages", title: "Direct Messages",
children: await Future.wait( children: await Future.wait(
client.rooms client.rooms
.where((room) => room.isDirectChat) .where((room) => room.isDirectChat)
.map((room) => room.fullRoom), .map((room) => room.fullRoom),
), ),
avatar: Icon(Icons.person), icon: Icon(Icons.person),
fake: true, fake: true,
), ),
...(await Future.wait( ...(await Future.wait(
topLevelSpaces.map( topLevelSpaces.map(
(space) async => Space( (space) async => Space(
client: client,
title: space.title, title: space.title,
avatar: space.avatar, avatar: space.avatar,
children: await Future.wait( children: await Future.wait(

View file

@ -0,0 +1,21 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class TimelineController extends AsyncNotifier<Timeline> {
TimelineController(this.room);
final Room room;
@override
Future<Timeline> build() => room.getTimeline();
Future<void> prev() async {
final timeline = await future;
await timeline.requestHistory();
state = AsyncValue.data(timeline);
}
static final provider =
AsyncNotifierProvider.family<TimelineController, Timeline, Room>(
TimelineController.new,
);
}

View file

@ -21,23 +21,15 @@ extension BetterWhen<T> on AsyncValue<T> {
} }
extension GetFullRoom on Room { extension GetFullRoom on Room {
Future<FullRoom> get fullRoom async { Future<FullRoom> get fullRoom async => FullRoom(
return FullRoom( roomData: this,
roomData: this, title: getLocalizedDisplayname(),
title: getLocalizedDisplayname(), avatar: await avatar?.getThumbnailUri(client, width: 24, height: 24),
avatar: await avatar?.asImage(client), );
);
}
} }
extension GetImage on Uri { extension GetHeaders on Client {
Future<Image?> asImage(Client client) async { Map<String, String> get headers => {"authorization": "Bearer $accessToken"};
final thumb = await getThumbnailUri(client, width: 24, height: 24);
return Image.network(
thumb.toString(),
headers: {"authorization": "Bearer ${client.accessToken}"},
);
}
} }
extension ToMessage on Event { extension ToMessage on Event {

View file

@ -1,4 +1,3 @@
import "package:flutter/widgets.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:matrix/matrix.dart"; import "package:matrix/matrix.dart";
part "full_room.freezed.dart"; part "full_room.freezed.dart";
@ -8,6 +7,6 @@ abstract class FullRoom with _$FullRoom {
const factory FullRoom({ const factory FullRoom({
required Room roomData, required Room roomData,
required String title, required String title,
required Image? avatar, required Uri? avatar,
}) = _FullRoom; }) = _FullRoom;
} }

View file

@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$FullRoom { mixin _$FullRoom {
Room get roomData; String get title; Image? get avatar; Room get roomData; String get title; Uri? get avatar;
/// Create a copy of FullRoom /// Create a copy of FullRoom
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -45,7 +45,7 @@ abstract mixin class $FullRoomCopyWith<$Res> {
factory $FullRoomCopyWith(FullRoom value, $Res Function(FullRoom) _then) = _$FullRoomCopyWithImpl; factory $FullRoomCopyWith(FullRoom value, $Res Function(FullRoom) _then) = _$FullRoomCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
Room roomData, String title, Image? avatar Room roomData, String title, Uri? avatar
}); });
@ -67,7 +67,7 @@ class _$FullRoomCopyWithImpl<$Res>
roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable
as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
as Image?, as Uri?,
)); ));
} }
@ -152,7 +152,7 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Room roomData, String title, Image? avatar)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Room roomData, String title, Uri? avatar)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _FullRoom() when $default != null: case _FullRoom() when $default != null:
return $default(_that.roomData,_that.title,_that.avatar);case _: return $default(_that.roomData,_that.title,_that.avatar);case _:
@ -173,7 +173,7 @@ return $default(_that.roomData,_that.title,_that.avatar);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Room roomData, String title, Image? avatar) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Room roomData, String title, Uri? avatar) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _FullRoom(): case _FullRoom():
return $default(_that.roomData,_that.title,_that.avatar);case _: return $default(_that.roomData,_that.title,_that.avatar);case _:
@ -193,7 +193,7 @@ return $default(_that.roomData,_that.title,_that.avatar);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Room roomData, String title, Image? avatar)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Room roomData, String title, Uri? avatar)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _FullRoom() when $default != null: case _FullRoom() when $default != null:
return $default(_that.roomData,_that.title,_that.avatar);case _: return $default(_that.roomData,_that.title,_that.avatar);case _:
@ -213,7 +213,7 @@ class _FullRoom implements FullRoom {
@override final Room roomData; @override final Room roomData;
@override final String title; @override final String title;
@override final Image? avatar; @override final Uri? avatar;
/// Create a copy of FullRoom /// Create a copy of FullRoom
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -245,7 +245,7 @@ abstract mixin class _$FullRoomCopyWith<$Res> implements $FullRoomCopyWith<$Res>
factory _$FullRoomCopyWith(_FullRoom value, $Res Function(_FullRoom) _then) = __$FullRoomCopyWithImpl; factory _$FullRoomCopyWith(_FullRoom value, $Res Function(_FullRoom) _then) = __$FullRoomCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
Room roomData, String title, Image? avatar Room roomData, String title, Uri? avatar
}); });
@ -267,7 +267,7 @@ class __$FullRoomCopyWithImpl<$Res>
roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable
as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
as Image?, as Uri?,
)); ));
} }

View file

@ -1,5 +1,6 @@
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:matrix/matrix.dart";
import "package:nexus/models/full_room.dart"; import "package:nexus/models/full_room.dart";
part "space.freezed.dart"; part "space.freezed.dart";
@ -7,8 +8,10 @@ part "space.freezed.dart";
abstract class Space with _$Space { abstract class Space with _$Space {
const factory Space({ const factory Space({
required String title, required String title,
required Widget? avatar,
required List<FullRoom> children, required List<FullRoom> children,
required Client client,
@Default(false) bool fake, @Default(false) bool fake,
Uri? avatar,
Icon? icon,
}) = _Space; }) = _Space;
} }

View file

@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$Space { mixin _$Space {
String get title; Widget? get avatar; List<FullRoom> get children; bool get fake; String get title; List<FullRoom> get children; Client get client; bool get fake; Uri? get avatar; Icon? get icon;
/// Create a copy of Space /// Create a copy of Space
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -25,16 +25,16 @@ $SpaceCopyWith<Space> get copyWith => _$SpaceCopyWithImpl<Space>(this as Space,
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Space&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&const DeepCollectionEquality().equals(other.children, children)&&(identical(other.fake, fake) || other.fake == fake)); return identical(this, other) || (other.runtimeType == runtimeType&&other is Space&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other.children, children)&&(identical(other.client, client) || other.client == client)&&(identical(other.fake, fake) || other.fake == fake)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.icon, icon) || other.icon == icon));
} }
@override @override
int get hashCode => Object.hash(runtimeType,title,avatar,const DeepCollectionEquality().hash(children),fake); int get hashCode => Object.hash(runtimeType,title,const DeepCollectionEquality().hash(children),client,fake,avatar,icon);
@override @override
String toString() { String toString() {
return 'Space(title: $title, avatar: $avatar, children: $children, fake: $fake)'; return 'Space(title: $title, children: $children, client: $client, fake: $fake, avatar: $avatar, icon: $icon)';
} }
@ -45,7 +45,7 @@ abstract mixin class $SpaceCopyWith<$Res> {
factory $SpaceCopyWith(Space value, $Res Function(Space) _then) = _$SpaceCopyWithImpl; factory $SpaceCopyWith(Space value, $Res Function(Space) _then) = _$SpaceCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String title, Widget? avatar, List<FullRoom> children, bool fake String title, List<FullRoom> children, Client client, bool fake, Uri? avatar, Icon? icon
}); });
@ -62,13 +62,15 @@ class _$SpaceCopyWithImpl<$Res>
/// Create a copy of Space /// Create a copy of Space
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? avatar = freezed,Object? children = null,Object? fake = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? children = null,Object? client = null,Object? fake = null,Object? avatar = freezed,Object? icon = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable as String,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable
as Widget?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable as List<FullRoom>,client: null == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as List<FullRoom>,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable as Client,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable
as bool, as bool,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
as Uri?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as Icon?,
)); ));
} }
@ -153,10 +155,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String title, Widget? avatar, List<FullRoom> children, bool fake)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String title, List<FullRoom> children, Client client, bool fake, Uri? avatar, Icon? icon)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _Space() when $default != null: case _Space() when $default != null:
return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _:
return orElse(); return orElse();
} }
@ -174,10 +176,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String title, Widget? avatar, List<FullRoom> children, bool fake) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String title, List<FullRoom> children, Client client, bool fake, Uri? avatar, Icon? icon) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Space(): case _Space():
return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@ -194,10 +196,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String title, Widget? avatar, List<FullRoom> children, bool fake)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String title, List<FullRoom> children, Client client, bool fake, Uri? avatar, Icon? icon)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Space() when $default != null: case _Space() when $default != null:
return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _:
return null; return null;
} }
@ -209,11 +211,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _:
class _Space implements Space { class _Space implements Space {
const _Space({required this.title, required this.avatar, required final List<FullRoom> children, this.fake = false}): _children = children; const _Space({required this.title, required final List<FullRoom> children, required this.client, this.fake = false, this.avatar, this.icon}): _children = children;
@override final String title; @override final String title;
@override final Widget? avatar;
final List<FullRoom> _children; final List<FullRoom> _children;
@override List<FullRoom> get children { @override List<FullRoom> get children {
if (_children is EqualUnmodifiableListView) return _children; if (_children is EqualUnmodifiableListView) return _children;
@ -221,7 +222,10 @@ class _Space implements Space {
return EqualUnmodifiableListView(_children); return EqualUnmodifiableListView(_children);
} }
@override final Client client;
@override@JsonKey() final bool fake; @override@JsonKey() final bool fake;
@override final Uri? avatar;
@override final Icon? icon;
/// Create a copy of Space /// Create a copy of Space
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -233,16 +237,16 @@ _$SpaceCopyWith<_Space> get copyWith => __$SpaceCopyWithImpl<_Space>(this, _$ide
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Space&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&const DeepCollectionEquality().equals(other._children, _children)&&(identical(other.fake, fake) || other.fake == fake)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _Space&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other._children, _children)&&(identical(other.client, client) || other.client == client)&&(identical(other.fake, fake) || other.fake == fake)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.icon, icon) || other.icon == icon));
} }
@override @override
int get hashCode => Object.hash(runtimeType,title,avatar,const DeepCollectionEquality().hash(_children),fake); int get hashCode => Object.hash(runtimeType,title,const DeepCollectionEquality().hash(_children),client,fake,avatar,icon);
@override @override
String toString() { String toString() {
return 'Space(title: $title, avatar: $avatar, children: $children, fake: $fake)'; return 'Space(title: $title, children: $children, client: $client, fake: $fake, avatar: $avatar, icon: $icon)';
} }
@ -253,7 +257,7 @@ abstract mixin class _$SpaceCopyWith<$Res> implements $SpaceCopyWith<$Res> {
factory _$SpaceCopyWith(_Space value, $Res Function(_Space) _then) = __$SpaceCopyWithImpl; factory _$SpaceCopyWith(_Space value, $Res Function(_Space) _then) = __$SpaceCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String title, Widget? avatar, List<FullRoom> children, bool fake String title, List<FullRoom> children, Client client, bool fake, Uri? avatar, Icon? icon
}); });
@ -270,13 +274,15 @@ class __$SpaceCopyWithImpl<$Res>
/// Create a copy of Space /// Create a copy of Space
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? avatar = freezed,Object? children = null,Object? fake = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? children = null,Object? client = null,Object? fake = null,Object? avatar = freezed,Object? icon = freezed,}) {
return _then(_Space( return _then(_Space(
title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable as String,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable
as Widget?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable as List<FullRoom>,client: null == client ? _self.client : client // ignore: cast_nullable_to_non_nullable
as List<FullRoom>,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable as Client,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable
as bool, as bool,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable
as Uri?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as Icon?,
)); ));
} }

View file

@ -0,0 +1,38 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/widgets.dart";
class AvatarOrHash extends StatelessWidget {
final Uri? avatar;
final String title;
final Widget? fallback;
final Map<String, String> headers;
const AvatarOrHash(
this.avatar,
this.title, {
this.fallback,
required this.headers,
super.key,
});
@override
Widget build(BuildContext context) {
final box = ColoredBox(
color: ColorHash(title).color,
child: Center(child: Text(title[0])),
);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(
width: 24,
height: 24,
child: avatar == null
? fallback ?? box
: Image.network(
avatar.toString(),
headers: headers,
errorBuilder: (_, _, _) => box,
),
),
);
}
}

64
lib/widgets/chat_box.dart Normal file
View file

@ -0,0 +1,64 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
class ChatBox extends StatelessWidget {
final Message? replyToMessage;
final VoidCallback onDismiss;
final Map<String, String> headers;
const ChatBox({
required this.replyToMessage,
required this.onDismiss,
required this.headers,
super.key,
});
@override
Widget build(BuildContext context) => Composer(
sigmaX: 0,
sigmaY: 0,
sendIconColor: Theme.of(context).colorScheme.primary,
sendOnEnter: true,
topWidget: replyToMessage == null
? null
: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
spacing: 8,
children: [
Avatar(
userId: replyToMessage!.authorId,
headers: headers,
size: 16,
),
Text(
replyToMessage!.metadata?["displayName"] ??
replyToMessage!.authorId,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: (replyToMessage is TextMessage)
? Text(
(replyToMessage as TextMessage).text,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
)
: SizedBox(),
),
IconButton(
onPressed: onDismiss,
icon: Icon(Icons.close),
iconSize: 20,
),
],
),
),
),
autofocus: true,
);
}

View file

@ -0,0 +1,53 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget {
final Room room;
const MemberList(this.room, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: SizedBox(
width: 240,
child: ref
.watch(MembersController.provider(room))
.betterWhen(
data: (members) => ListView(
children: [
...members
.where(
(membership) =>
membership.content["membership"] ==
Membership.join.name,
)
.map(
(member) => ListTile(
leading: AvatarOrHash(
ref
.watch(
AvatarController.provider(
member.content["avatar_url"].toString(),
),
)
.whenOrNull(data: (data) => data),
member.content["displayname"].toString(),
headers: room.client.headers,
),
title: Text(
member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
);
}

View file

@ -0,0 +1,66 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
final bool isDesktop;
final FullRoom room;
final VoidCallback onOpenMemberList;
final VoidCallback onOpenDrawer;
const RoomAppbar(
this.room, {
required this.isDesktop,
required this.onOpenMemberList,
required this.onOpenDrawer,
super.key,
});
@override
Size get preferredSize => Size.fromHeight(AppBar().preferredSize.height + 16);
@override
AppBar build(BuildContext context) => AppBar(
bottom: PreferredSize(
preferredSize: Size.zero, // Does this even matter??
child: Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(8).copyWith(top: 0),
child: Text(
room.roomData.topic,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
leading: isDesktop ? null : DrawerButton(onPressed: onOpenDrawer),
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: Row(
children: [
AvatarOrHash(
room.avatar,
room.title,
fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
),
SizedBox(width: 12),
Expanded(child: Text(room.title, overflow: TextOverflow.ellipsis)),
],
),
actions: [
IconButton(onPressed: onOpenMemberList, icon: Icon(Icons.people)),
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)),
],
);
}

View file

@ -1,25 +0,0 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/widgets.dart";
class RoomAvatar extends StatelessWidget {
final Widget? avatar;
final String title;
final Widget? fallback;
const RoomAvatar(this.avatar, this.title, {this.fallback, super.key});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(
width: 24,
height: 24,
child:
avatar ??
fallback ??
ColoredBox(
color: ColorHash(title).color,
child: Center(child: Text(title[0])),
),
),
);
}

View file

@ -1,5 +1,3 @@
import "dart:io";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
@ -15,8 +13,10 @@ import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/chat_box.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/top_widget.dart"; import "package:nexus/widgets/top_widget.dart";
import "package:nexus/widgets/room_avatar.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
@ -33,12 +33,18 @@ class RoomChat extends HookConsumerWidget {
Offset.zero & (context.findRenderObject() as RenderBox).size, Offset.zero & (context.findRenderObject() as RenderBox).size,
), ),
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
items: [PopupMenuItem(onTap: onTap, child: Text("Reply"))], items: [
PopupMenuItem(
onTap: onTap,
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
],
); );
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final replyToMessage = useState<Message?>(null); final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(isDesktop);
final urlRegex = RegExp(r"https?://[^\s\]\(\)]+"); final urlRegex = RegExp(r"https?://[^\s\]\(\)]+");
final theme = Theme.of(context); final theme = Theme.of(context);
return ref return ref
@ -48,250 +54,200 @@ class RoomChat extends HookConsumerWidget {
final controllerProvider = RoomChatController.provider( final controllerProvider = RoomChatController.provider(
room.roomData, room.roomData,
); );
final headers = {
"authorization": "Bearer ${room.roomData.client.accessToken}",
};
return Scaffold( return Scaffold(
appBar: AppBar( appBar: RoomAppbar(
leading: isDesktop room,
? null isDesktop: isDesktop,
: DrawerButton(onPressed: Scaffold.of(context).openDrawer), onOpenDrawer: Scaffold.of(context).openDrawer,
actionsPadding: EdgeInsets.symmetric(horizontal: 8), onOpenMemberList: () =>
title: Row( memberListOpened.value = !memberListOpened.value,
children: [
RoomAvatar(
room.avatar,
room.title,
fallback: Icon(Icons.numbers),
),
SizedBox(width: 12),
Expanded(
child: Text(room.title, overflow: TextOverflow.ellipsis),
),
],
),
actions: [
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(
onPressed: () => exit(0),
icon: Icon(Icons.close),
),
],
), ),
body: ref body: Row(
.watch(controllerProvider) children: [
.betterWhen( Expanded(
data: (controller) => Chat( child: ref
currentUserId: room.roomData.client.userID!, .watch(controllerProvider)
theme: ChatTheme.fromThemeData(theme).copyWith( .betterWhen(
colors: ChatColors.fromThemeData(theme).copyWith( data: (controller) => Chat(
primary: theme.colorScheme.primaryContainer, currentUserId: room.roomData.client.userID!,
onPrimary: theme.colorScheme.onPrimaryContainer, theme: ChatTheme.fromThemeData(theme).copyWith(
), colors: ChatColors.fromThemeData(theme).copyWith(
), primary: theme.colorScheme.primaryContainer,
onMessageSecondaryTap: onPrimary: theme.colorScheme.onPrimaryContainer,
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
builders: Builders(
composerBuilder: (_) => Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (replyToMessage.value != null)
ColoredBox(
color: theme.colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
spacing: 8,
children: [
Avatar(
userId: replyToMessage.value!.authorId,
headers: headers,
size: 16,
),
Text(
replyToMessage
.value!
.metadata?["displayName"] ??
replyToMessage.value!.authorId,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: (replyToMessage.value as dynamic)
? Text(
(replyToMessage.value
as TextMessage)
.text,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelMedium,
maxLines: 1,
)
: SizedBox(),
),
IconButton(
onPressed: () =>
replyToMessage.value = null,
icon: Icon(Icons.close),
iconSize: 20,
),
],
),
),
), ),
Composer(
sigmaX: 0,
sigmaY: 0,
sendIconColor: theme.colorScheme.primary,
sendOnEnter: true,
autofocus: true,
), ),
], onMessageSecondaryTap:
), (
unsupportedMessageBuilder: context,
( message, {
_, required details,
message, required index,
index, { }) => showContextMenu(
required bool isSentByMe, context: context,
MessageGroupStatus? groupStatus, globalPosition: details.globalPosition,
}) => kDebugMode onTap: () => replyToMessage.value = message,
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
), ),
) onMessageLongPress:
: SizedBox.shrink(), (
textMessageBuilder: context,
( message, {
context, required details,
message, required index,
index, { }) => showContextMenu(
required bool isSentByMe, context: context,
MessageGroupStatus? groupStatus, globalPosition: details.globalPosition,
}) => FlyerChatTextMessage( onTap: () => replyToMessage.value = message,
topWidget: TopWidget(message, headers: headers),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
(match) =>
"[${match.group(0)}](${match.group(0)})",
), ),
builders: Builders(
chatAnimatedListBuilder: (context, itemBuilder) {
return ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: ref
.watch(controllerProvider.notifier)
.loadOlder,
);
},
composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value,
onDismiss: () => replyToMessage.value = null,
headers: room.roomData.client.headers,
), ),
showTime: true, textMessageBuilder:
index: index, (
onLinkTap: (url, _) => ref context,
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
linksDecoration: TextDecoration.underline,
sentLinksColor: Colors.blue,
receivedLinksColor: Colors.blue,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text:
urlRegex.firstMatch(message.text)?.group(0) ??
"",
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched: (linkPreviewData) => ref
.watch(controllerProvider.notifier)
.updateMessage(
message, message,
message.copyWith( index, {
linkPreviewData: linkPreviewData, required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
(match) =>
"[${match.group(0)}](${match.group(0)})",
),
),
showTime: true,
index: index,
onLinkTap: (url, _) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
linksDecoration: TextDecoration.underline,
sentLinksColor: Colors.blue,
receivedLinksColor: Colors.blue,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text:
urlRegex
.firstMatch(message.text)
?.group(0) ??
"",
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) => ref
.watch(controllerProvider.notifier)
.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
headers: room.roomData.client.headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
), ),
), ),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall
?.copyWith(color: Colors.grey),
)
: SizedBox.shrink(),
), ),
imageMessageBuilder: onMessageSend: (message) {
( ref
_, .watch(controllerProvider.notifier)
message, .send(message, replyTo: replyToMessage.value);
index, { replyToMessage.value = null;
required bool isSentByMe, },
MessageGroupStatus? groupStatus, resolveUser: ref
}) => FlyerChatImageMessage( .watch(controllerProvider.notifier)
topWidget: TopWidget(message, headers: headers), .resolveUser,
message: message, chatController: controller,
index: index, ),
headers: headers, ),
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(message, headers: headers),
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
),
onMessageSend: (message) {
ref
.watch(controllerProvider.notifier)
.send(message, replyTo: replyToMessage.value);
replyToMessage.value = null;
},
resolveUser: ref
.watch(controllerProvider.notifier)
.resolveUser,
chatController: controller,
),
), ),
if (memberListOpened.value == true) MemberList(room.roomData),
],
),
); );
}, },
); );

View file

@ -4,7 +4,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/current_room_controller.dart"; import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/room_avatar.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
const Sidebar({super.key}); const Sidebar({super.key});
@ -38,7 +38,12 @@ class Sidebar extends HookConsumerWidget {
destinations: spaces destinations: spaces
.map( .map(
(space) => NavigationRailDestination( (space) => NavigationRailDestination(
icon: RoomAvatar(space.avatar, space.title), icon: AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
label: Text(space.title), label: Text(space.title),
padding: EdgeInsets.only(top: 4), padding: EdgeInsets.only(top: 4),
), ),
@ -58,7 +63,12 @@ class Sidebar extends HookConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
RoomAvatar(space.avatar, space.title), AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
SizedBox(width: 12), SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
@ -80,12 +90,13 @@ class Sidebar extends HookConsumerWidget {
destinations: space.children destinations: space.children
.map( .map(
(room) => NavigationRailDestination( (room) => NavigationRailDestination(
icon: RoomAvatar( icon: AvatarOrHash(
room.avatar, room.avatar,
room.title, room.title,
fallback: selectedSpace.value == 1 fallback: selectedSpace.value == 1
? null ? null
: Icon(Icons.numbers), : Icon(Icons.numbers),
headers: space.client.headers,
), ),
label: Text(room.title), label: Text(room.title),
), ),

View file

@ -10,7 +10,13 @@ import "package:nexus/helpers/extension_helper.dart";
class TopWidget extends ConsumerWidget { class TopWidget extends ConsumerWidget {
final Message message; final Message message;
final Map<String, String> headers; final Map<String, String> headers;
const TopWidget(this.message, {required this.headers, super.key}); final MessageGroupStatus? groupStatus;
const TopWidget(
this.message, {
required this.headers,
required this.groupStatus,
super.key,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) => Column( Widget build(BuildContext context, WidgetRef ref) => Column(
@ -32,16 +38,14 @@ class TopWidget extends ConsumerWidget {
(message as TextMessage).text.length - 20, (message as TextMessage).text.length - 20,
replyMessage.text.length, replyMessage.text.length,
), ),
40, 5,
), ),
replyMessage.text.length, replyMessage.text.length,
), ),
) )
: replyMessage.text; : replyMessage.text;
return InkWell( return InkWell(
onTap: () => showAboutDialog( onTap: () => showAboutDialog(context: context),
context: context,
), // TODO: Scroll to message
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
@ -62,11 +66,14 @@ class TopWidget extends ConsumerWidget {
headers: headers, headers: headers,
size: 16, size: 16,
), ),
Text( Flexible(
replyMessage.metadata?["displayName"] ?? child: Text(
replyMessage.authorId, replyMessage.metadata?["displayName"] ??
style: Theme.of(context).textTheme.labelMedium replyMessage.authorId,
?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
), ),
Flexible( Flexible(
child: Text( child: Text(
@ -85,23 +92,27 @@ class TopWidget extends ConsumerWidget {
), ),
SizedBox(height: 12), SizedBox(height: 12),
], ],
InkWell( if (groupStatus?.isFirst != false)
onTap: () => InkWell(
showAboutDialog(context: context), // TODO: Show user profile onTap: () =>
child: Row( showAboutDialog(context: context), // TODO: Show user profile
mainAxisSize: MainAxisSize.min, child: Row(
spacing: 8, mainAxisSize: MainAxisSize.min,
children: [ spacing: 8,
Avatar(userId: message.authorId, headers: headers), children: [
Text( Avatar(userId: message.authorId, headers: headers),
message.metadata?["displayName"] ?? message.authorId, Flexible(
style: Theme.of( child: Text(
context, message.metadata?["displayName"] ?? message.authorId,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis,
), style: Theme.of(context).textTheme.titleMedium?.copyWith(
], fontWeight: FontWeight.bold,
),
),
),
],
),
), ),
),
SizedBox(height: 4), SizedBox(height: 4),
], ],
); );