Merge branch 'krille-chan:main' into main
This commit is contained in:
commit
c277e73faf
89 changed files with 24773 additions and 27791 deletions
|
|
@ -12,7 +12,7 @@ abstract class AppConfig {
|
|||
static double fontSizeFactor = 1;
|
||||
static const Color chatColor = primaryColor;
|
||||
static Color? colorSchemeSeed = primaryColor;
|
||||
static const double messageFontSize = 15.75;
|
||||
static const double messageFontSize = 16.0;
|
||||
static const bool allowOtherHomeservers = true;
|
||||
static const bool enableRegistration = true;
|
||||
static const Color primaryColor = Color(0xFF5625BA);
|
||||
|
|
@ -35,6 +35,8 @@ abstract class AppConfig {
|
|||
'https://github.com/krille-chan/fluffychat';
|
||||
static const String supportUrl =
|
||||
'https://github.com/krille-chan/fluffychat/issues';
|
||||
static const String changelogUrl =
|
||||
'https://github.com/krille-chan/fluffychat/blob/main/CHANGELOG.md';
|
||||
static final Uri newIssueUrl = Uri(
|
||||
scheme: 'https',
|
||||
host: 'github.com',
|
||||
|
|
|
|||
|
|
@ -77,9 +77,6 @@ abstract class FluffyThemes {
|
|||
? Typography.material2018().black.merge(fallbackTextTheme)
|
||||
: Typography.material2018().white.merge(fallbackTextTheme)
|
||||
: null,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
dividerColor: brightness == Brightness.light
|
||||
? Colors.blueGrey.shade50
|
||||
: Colors.blueGrey.shade900,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import 'package:fluffychat/pages/chat/recording_dialog.dart';
|
|||
import 'package:fluffychat/pages/chat_details/chat_details.dart';
|
||||
import 'package:fluffychat/utils/error_reporter.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/app_lock.dart';
|
||||
|
|
@ -292,7 +293,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
if (timeline?.events.any((event) => event.eventId == fullyRead) ??
|
||||
false) {
|
||||
Logs().v('Scroll up to visible event', fullyRead);
|
||||
setReadMarker();
|
||||
scrollToEventId(fullyRead, highlightEvent: false);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
|
@ -620,10 +621,10 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
builder: (c) => const RecordingDialog(),
|
||||
);
|
||||
if (result == null) return;
|
||||
final audioFile = File(result.path);
|
||||
final audioFile = XFile(result.path);
|
||||
final file = MatrixAudioFile(
|
||||
bytes: audioFile.readAsBytesSync(),
|
||||
name: audioFile.path,
|
||||
bytes: await audioFile.readAsBytes(),
|
||||
name: result.fileName ?? audioFile.path,
|
||||
);
|
||||
await room.sendFileEvent(
|
||||
file,
|
||||
|
|
@ -900,8 +901,14 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
inputFocus.requestFocus();
|
||||
}
|
||||
|
||||
void scrollToEventId(String eventId) async {
|
||||
final eventIndex = timeline!.events.indexWhere((e) => e.eventId == eventId);
|
||||
void scrollToEventId(
|
||||
String eventId, {
|
||||
bool highlightEvent = true,
|
||||
}) async {
|
||||
final eventIndex = timeline!.events
|
||||
.where((event) => event.isVisibleInGui)
|
||||
.toList()
|
||||
.indexWhere((e) => e.eventId == eventId);
|
||||
if (eventIndex == -1) {
|
||||
setState(() {
|
||||
timeline = null;
|
||||
|
|
@ -917,11 +924,14 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
scrollToEventIdMarker = eventId;
|
||||
});
|
||||
if (highlightEvent) {
|
||||
setState(() {
|
||||
scrollToEventIdMarker = eventId;
|
||||
});
|
||||
}
|
||||
await scrollController.scrollToIndex(
|
||||
eventIndex,
|
||||
eventIndex + 1,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
_updateScrollController();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/utils/url_launcher.dart';
|
||||
|
||||
class ChatAppBarListTile extends StatelessWidget {
|
||||
|
|
@ -11,6 +10,8 @@ class ChatAppBarListTile extends StatelessWidget {
|
|||
final Widget? trailing;
|
||||
final void Function()? onTap;
|
||||
|
||||
static const double fixedHeight = 40.0;
|
||||
|
||||
const ChatAppBarListTile({
|
||||
super.key,
|
||||
this.leading,
|
||||
|
|
@ -23,38 +24,40 @@ class ChatAppBarListTile extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final leading = this.leading;
|
||||
final trailing = this.trailing;
|
||||
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Linkify(
|
||||
text: title,
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
return SizedBox(
|
||||
height: fixedHeight,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Linkify(
|
||||
text: title,
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,15 +158,15 @@ class ChatView extends StatelessWidget {
|
|||
builder: (BuildContext context, snapshot) {
|
||||
var appbarBottomHeight = 0.0;
|
||||
if (controller.room.pinnedEventIds.isNotEmpty) {
|
||||
appbarBottomHeight += 42;
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
if (scrollUpBannerEventId != null) {
|
||||
appbarBottomHeight += 42;
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
final tombstoneEvent =
|
||||
controller.room.getState(EventTypes.RoomTombstone);
|
||||
if (tombstoneEvent != null) {
|
||||
appbarBottomHeight += 42;
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -182,10 +182,17 @@ class ChatView extends StatelessWidget {
|
|||
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()),
|
||||
: StreamBuilder<Object>(
|
||||
stream: Matrix.of(context)
|
||||
.client
|
||||
.onSync
|
||||
.stream
|
||||
.where((syncUpdate) => syncUpdate.hasRoomUpdate),
|
||||
builder: (context, _) => UnreadRoomsBadge(
|
||||
filter: (r) => r.id != controller.roomId,
|
||||
badgePosition: BadgePosition.topEnd(end: 8, top: 4),
|
||||
child: const Center(child: BackButton()),
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
title: ChatAppBarTitle(controller),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:fluffychat/utils/error_reporter.dart';
|
||||
|
|
@ -70,7 +71,18 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
|
|||
widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
|
||||
);
|
||||
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
|
||||
|
||||
await file.writeAsBytes(matrixFile.bytes);
|
||||
|
||||
if (Platform.isIOS &&
|
||||
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
|
||||
Logs().v('Convert ogg audio file for iOS...');
|
||||
final convertedFile = File('${file.path}.caf');
|
||||
if (await convertedFile.exists() == false) {
|
||||
OpusCaf().convertOpusToCaf(file.path, convertedFile.path);
|
||||
}
|
||||
file = convertedFile;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -280,7 +280,6 @@ class ImageExtension extends HtmlExtension {
|
|||
uri: mxcUrl,
|
||||
width: width ?? height ?? defaultDimension,
|
||||
height: height ?? width ?? defaultDimension,
|
||||
cacheKey: mxcUrl.toString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -435,22 +435,20 @@ class Message extends StatelessWidget {
|
|||
? const EdgeInsets.symmetric(vertical: 8.0)
|
||||
: EdgeInsets.zero,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: displayTime
|
||||
? Theme.of(context).colorScheme.surface
|
||||
: Theme.of(context).colorScheme.surface.withOpacity(0.33),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
event.originServerTs.localizedTime(context),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12 * AppConfig.fontSizeFactor,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
event.originServerTs.localizedTime(context),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12 * AppConfig.fontSizeFactor,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
blurRadius: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -17,10 +17,6 @@ class StateMessage extends StatelessWidget {
|
|||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
child: Text(
|
||||
event.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
|
|
@ -29,6 +25,12 @@ class StateMessage extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
fontSize: 12 * AppConfig.fontSizeFactor,
|
||||
decoration: event.redacted ? TextDecoration.lineThrough : null,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
blurRadius: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ class PinnedEvents extends StatelessWidget {
|
|||
) ??
|
||||
L10n.of(context)!.loadingPleaseWait,
|
||||
leading: IconButton(
|
||||
splashRadius: 20,
|
||||
iconSize: 20,
|
||||
splashRadius: 18,
|
||||
iconSize: 18,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
icon: const Icon(Icons.push_pin),
|
||||
tooltip: L10n.of(context)!.unpin,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:path/path.dart' as path_lib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
|
@ -13,7 +15,6 @@ import 'package:fluffychat/utils/platform_infos.dart';
|
|||
import 'events/audio_player.dart';
|
||||
|
||||
class RecordingDialog extends StatefulWidget {
|
||||
static const String recordingFileType = 'm4a';
|
||||
const RecordingDialog({
|
||||
super.key,
|
||||
});
|
||||
|
|
@ -27,18 +28,32 @@ class RecordingDialogState extends State<RecordingDialog> {
|
|||
Duration _duration = Duration.zero;
|
||||
|
||||
bool error = false;
|
||||
String? _recordedPath;
|
||||
|
||||
final _audioRecorder = AudioRecorder();
|
||||
final List<double> amplitudeTimeline = [];
|
||||
|
||||
String? fileName;
|
||||
|
||||
static const int bitRate = 64000;
|
||||
static const int samplingRate = 44100;
|
||||
|
||||
Future<void> startRecording() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final path = _recordedPath =
|
||||
'${tempDir.path}/recording${DateTime.now().microsecondsSinceEpoch}.${RecordingDialog.recordingFileType}';
|
||||
final codec = kIsWeb
|
||||
// Web seems to create webm instead of ogg when using opus encoder
|
||||
// which does not play on iOS right now. So we use wav for now:
|
||||
? AudioEncoder.wav
|
||||
// Everywhere else we use opus if supported by the platform:
|
||||
: await _audioRecorder.isEncoderSupported(AudioEncoder.opus)
|
||||
? AudioEncoder.opus
|
||||
: AudioEncoder.aacLc;
|
||||
fileName =
|
||||
'recording${DateTime.now().microsecondsSinceEpoch}.${codec.fileExtension}';
|
||||
String? path;
|
||||
if (!kIsWeb) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
path = path_lib.join(tempDir.path, fileName);
|
||||
}
|
||||
|
||||
final result = await _audioRecorder.hasPermission();
|
||||
if (result != true) {
|
||||
|
|
@ -46,16 +61,18 @@ class RecordingDialogState extends State<RecordingDialog> {
|
|||
return;
|
||||
}
|
||||
await WakelockPlus.enable();
|
||||
|
||||
await _audioRecorder.start(
|
||||
const RecordConfig(
|
||||
RecordConfig(
|
||||
bitRate: bitRate,
|
||||
sampleRate: samplingRate,
|
||||
numChannels: 1,
|
||||
autoGain: true,
|
||||
echoCancel: true,
|
||||
noiseSuppress: true,
|
||||
encoder: codec,
|
||||
),
|
||||
path: path,
|
||||
path: path ?? '',
|
||||
);
|
||||
setState(() => _duration = Duration.zero);
|
||||
_recorderSubscription?.cancel();
|
||||
|
|
@ -91,8 +108,8 @@ class RecordingDialogState extends State<RecordingDialog> {
|
|||
|
||||
void _stopAndSend() async {
|
||||
_recorderSubscription?.cancel();
|
||||
await _audioRecorder.stop();
|
||||
final path = _recordedPath;
|
||||
final path = await _audioRecorder.stop();
|
||||
|
||||
if (path == null) throw ('Recording failed!');
|
||||
const waveCount = AudioPlayerWidget.wavesCount;
|
||||
final step = amplitudeTimeline.length < waveCount
|
||||
|
|
@ -107,6 +124,7 @@ class RecordingDialogState extends State<RecordingDialog> {
|
|||
path: path,
|
||||
duration: _duration.inMilliseconds,
|
||||
waveform: waveform,
|
||||
fileName: fileName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -217,23 +235,32 @@ class RecordingResult {
|
|||
final String path;
|
||||
final int duration;
|
||||
final List<int> waveform;
|
||||
final String? fileName;
|
||||
|
||||
const RecordingResult({
|
||||
required this.path,
|
||||
required this.duration,
|
||||
required this.waveform,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
factory RecordingResult.fromJson(Map<String, dynamic> json) =>
|
||||
RecordingResult(
|
||||
path: json['path'],
|
||||
duration: json['duration'],
|
||||
waveform: List<int>.from(json['waveform']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'duration': duration,
|
||||
'waveform': waveform,
|
||||
};
|
||||
}
|
||||
|
||||
extension on AudioEncoder {
|
||||
String get fileExtension {
|
||||
switch (this) {
|
||||
case AudioEncoder.aacLc:
|
||||
case AudioEncoder.aacEld:
|
||||
case AudioEncoder.aacHe:
|
||||
return 'm4a';
|
||||
case AudioEncoder.opus:
|
||||
return 'ogg';
|
||||
case AudioEncoder.wav:
|
||||
return 'wav';
|
||||
case AudioEncoder.amrNb:
|
||||
case AudioEncoder.amrWb:
|
||||
case AudioEncoder.flac:
|
||||
case AudioEncoder.pcm16bits:
|
||||
throw UnsupportedError('Not yet used');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -6,7 +8,7 @@ import 'package:future_loading_dialog/future_loading_dialog.dart';
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/error_reporter.dart';
|
||||
import 'package:fluffychat/utils/size_string.dart';
|
||||
import '../../utils/resize_image.dart';
|
||||
|
||||
|
|
@ -42,19 +44,20 @@ class SendFileDialogState extends State<SendFileDialog> {
|
|||
},
|
||||
);
|
||||
}
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
widget.room
|
||||
.sendFileEvent(
|
||||
file,
|
||||
thumbnail: thumbnail,
|
||||
shrinkImageMaxDimension: origImage ? null : 1600,
|
||||
)
|
||||
.catchError((e) {
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text((e as Object).toLocalizedString(context))),
|
||||
try {
|
||||
await widget.room.sendFileEvent(
|
||||
file,
|
||||
thumbnail: thumbnail,
|
||||
shrinkImageMaxDimension: origImage ? null : 1600,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
} on IOException catch (_) {
|
||||
} on FileTooBigMatrixException catch (_) {
|
||||
} catch (e, s) {
|
||||
if (mounted) {
|
||||
ErrorReporter(context, 'Unable to send file').onErrorCallback(e, s);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,36 @@ class ChatAccessSettingsController extends State<ChatAccessSettings> {
|
|||
bool guestAccessLoading = false;
|
||||
Room get room => Matrix.of(context).client.getRoomById(widget.roomId)!;
|
||||
|
||||
String get roomVersion =>
|
||||
room
|
||||
.getState(EventTypes.RoomCreate)!
|
||||
.content
|
||||
.tryGet<String>('room_version') ??
|
||||
'Unknown';
|
||||
|
||||
/// Calculates which join rules are available based on the information on
|
||||
/// https://spec.matrix.org/v1.11/rooms/#feature-matrix
|
||||
List<JoinRules> get availableJoinRules {
|
||||
final joinRules = Set<JoinRules>.from(JoinRules.values);
|
||||
|
||||
final roomVersionInt = int.tryParse(roomVersion);
|
||||
|
||||
// Knock is only supported for rooms up from version 7:
|
||||
if (roomVersionInt != null && roomVersionInt <= 6) {
|
||||
joinRules.remove(JoinRules.knock);
|
||||
}
|
||||
|
||||
// Not yet supported in FluffyChat:
|
||||
joinRules.remove(JoinRules.restricted);
|
||||
joinRules.remove(JoinRules.knockRestricted);
|
||||
|
||||
// If an unsupported join rule is the current join rule, display it:
|
||||
final currentJoinRule = room.joinRules;
|
||||
if (currentJoinRule != null) joinRules.add(currentJoinRule);
|
||||
|
||||
return joinRules.toList();
|
||||
}
|
||||
|
||||
void setJoinRule(JoinRules? newJoinRules) async {
|
||||
if (newJoinRules == null) return;
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class ChatAccessSettingsPageView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
for (final joinRule in JoinRules.values)
|
||||
for (final joinRule in controller.availableJoinRules)
|
||||
if (joinRule != JoinRules.private)
|
||||
RadioListTile<JoinRules>.adaptive(
|
||||
title: Text(
|
||||
|
|
|
|||
|
|
@ -85,33 +85,16 @@ class ChatDetailsView extends StatelessWidget {
|
|||
padding: const EdgeInsets.all(32.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
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: Hero(
|
||||
tag: controller
|
||||
.widget.embeddedCloseButton !=
|
||||
null
|
||||
? 'embedded_content_banner'
|
||||
: 'content_banner',
|
||||
child: Avatar(
|
||||
mxContent: room.avatar,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize * 2.5,
|
||||
),
|
||||
Hero(
|
||||
tag:
|
||||
controller.widget.embeddedCloseButton !=
|
||||
null
|
||||
? 'embedded_content_banner'
|
||||
: 'content_banner',
|
||||
child: Avatar(
|
||||
mxContent: room.avatar,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize * 2.5,
|
||||
),
|
||||
),
|
||||
if (!room.isDirectChat &&
|
||||
|
|
@ -170,7 +153,7 @@ class ChatDetailsView extends StatelessWidget {
|
|||
: displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// style: const TextStyle(fontSize: 18),
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
|
|
@ -202,10 +185,7 @@ class ChatDetailsView extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
if (!room.canChangeStateEvent(EventTypes.RoomTopic))
|
||||
ListTile(
|
||||
title: Text(
|
||||
|
|
@ -261,10 +241,7 @@ class ChatDetailsView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -316,10 +293,7 @@ class ChatDetailsView extends StatelessWidget {
|
|||
onTap: () => context
|
||||
.push('/rooms/${room.id}/details/permissions'),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
title: Text(
|
||||
L10n.of(context)!.countParticipants(
|
||||
|
|
|
|||
|
|
@ -10,16 +10,19 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
|
|||
import 'package:flutter_shortcuts/flutter_shortcuts.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:matrix/matrix.dart' as sdk;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:uni_links/uni_links.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/utils/show_update_snackbar.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import '../../../utils/account_bundles.dart';
|
||||
import '../../config/setting_keys.dart';
|
||||
import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart';
|
||||
|
|
@ -35,7 +38,6 @@ import 'package:fluffychat/utils/tor_stub.dart'
|
|||
enum SelectMode {
|
||||
normal,
|
||||
share,
|
||||
select,
|
||||
}
|
||||
|
||||
enum PopupMenuAction {
|
||||
|
|
@ -49,20 +51,38 @@ enum PopupMenuAction {
|
|||
|
||||
enum ActiveFilter {
|
||||
allChats,
|
||||
groups,
|
||||
messages,
|
||||
groups,
|
||||
unread,
|
||||
spaces,
|
||||
}
|
||||
|
||||
extension LocalizedActiveFilter on ActiveFilter {
|
||||
String toLocalizedString(BuildContext context) {
|
||||
switch (this) {
|
||||
case ActiveFilter.allChats:
|
||||
return L10n.of(context)!.all;
|
||||
case ActiveFilter.messages:
|
||||
return L10n.of(context)!.messages;
|
||||
case ActiveFilter.unread:
|
||||
return L10n.of(context)!.unread;
|
||||
case ActiveFilter.groups:
|
||||
return L10n.of(context)!.groups;
|
||||
case ActiveFilter.spaces:
|
||||
return L10n.of(context)!.spaces;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatList extends StatefulWidget {
|
||||
static BuildContext? contextForVoip;
|
||||
final bool displayNavigationRail;
|
||||
final String? activeChat;
|
||||
final bool displayNavigationRail;
|
||||
|
||||
const ChatList({
|
||||
super.key,
|
||||
this.displayNavigationRail = false,
|
||||
required this.activeChat,
|
||||
this.displayNavigationRail = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -77,85 +97,155 @@ class ChatListController extends State<ChatList>
|
|||
|
||||
StreamSubscription? _intentUriStreamSubscription;
|
||||
|
||||
bool get displayNavigationBar =>
|
||||
!FluffyThemes.isColumnMode(context) &&
|
||||
(spaces.isNotEmpty || AppConfig.separateChatTypes);
|
||||
|
||||
String? activeSpaceId;
|
||||
|
||||
void resetActiveSpaceId() {
|
||||
setState(() {
|
||||
selectedRoomIds.clear();
|
||||
activeSpaceId = null;
|
||||
});
|
||||
}
|
||||
|
||||
void setActiveSpace(String? spaceId) {
|
||||
setState(() {
|
||||
selectedRoomIds.clear();
|
||||
activeSpaceId = spaceId;
|
||||
activeFilter = ActiveFilter.spaces;
|
||||
});
|
||||
}
|
||||
|
||||
void createNewSpace() async {
|
||||
final spaceId = await context.push<String?>('/rooms/newspace');
|
||||
if (spaceId != null) {
|
||||
setActiveSpace(spaceId);
|
||||
}
|
||||
}
|
||||
|
||||
int get selectedIndex {
|
||||
switch (activeFilter) {
|
||||
case ActiveFilter.allChats:
|
||||
case ActiveFilter.messages:
|
||||
return 0;
|
||||
case ActiveFilter.groups:
|
||||
return 1;
|
||||
case ActiveFilter.spaces:
|
||||
return AppConfig.separateChatTypes ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
ActiveFilter getActiveFilterByDestination(int? i) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
if (AppConfig.separateChatTypes) {
|
||||
return ActiveFilter.groups;
|
||||
}
|
||||
return ActiveFilter.spaces;
|
||||
case 2:
|
||||
return ActiveFilter.spaces;
|
||||
case 0:
|
||||
default:
|
||||
if (AppConfig.separateChatTypes) {
|
||||
return ActiveFilter.messages;
|
||||
}
|
||||
return ActiveFilter.allChats;
|
||||
}
|
||||
}
|
||||
|
||||
void onDestinationSelected(int? i) {
|
||||
setState(() {
|
||||
selectedRoomIds.clear();
|
||||
activeFilter = getActiveFilterByDestination(i);
|
||||
});
|
||||
void createNewSpace() {
|
||||
context.push<String?>('/rooms/newspace');
|
||||
}
|
||||
|
||||
ActiveFilter activeFilter = AppConfig.separateChatTypes
|
||||
? ActiveFilter.messages
|
||||
: ActiveFilter.allChats;
|
||||
|
||||
String? _activeSpaceId;
|
||||
String? get activeSpaceId => _activeSpaceId;
|
||||
|
||||
void setActiveSpace(String spaceId) async {
|
||||
await Matrix.of(context).client.getRoomById(spaceId)!.postLoad();
|
||||
|
||||
setState(() {
|
||||
_activeSpaceId = spaceId;
|
||||
});
|
||||
}
|
||||
|
||||
void clearActiveSpace() => setState(() {
|
||||
_activeSpaceId = null;
|
||||
});
|
||||
|
||||
void onChatTap(Room room) async {
|
||||
if (room.membership == Membership.invite) {
|
||||
final inviterId =
|
||||
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
|
||||
final inviteAction = await showModalActionSheet<InviteActions>(
|
||||
context: context,
|
||||
message: room.isDirectChat
|
||||
? L10n.of(context)!.invitePrivateChat
|
||||
: L10n.of(context)!.inviteGroupChat,
|
||||
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
|
||||
actions: [
|
||||
SheetAction(
|
||||
key: InviteActions.accept,
|
||||
label: L10n.of(context)!.accept,
|
||||
icon: Icons.check_outlined,
|
||||
isDefaultAction: true,
|
||||
),
|
||||
SheetAction(
|
||||
key: InviteActions.decline,
|
||||
label: L10n.of(context)!.decline,
|
||||
icon: Icons.close_outlined,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
SheetAction(
|
||||
key: InviteActions.block,
|
||||
label: L10n.of(context)!.block,
|
||||
icon: Icons.block_outlined,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (inviteAction == null) return;
|
||||
if (inviteAction == InviteActions.block) {
|
||||
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
|
||||
return;
|
||||
}
|
||||
if (inviteAction == InviteActions.decline) {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: room.leave,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final joinResult = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final waitForRoom = room.client.waitForRoomInSync(
|
||||
room.id,
|
||||
join: true,
|
||||
);
|
||||
await room.join();
|
||||
await waitForRoom;
|
||||
},
|
||||
);
|
||||
if (joinResult.error != null) return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.ban) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.leave) {
|
||||
context.go('/rooms/archive/${room.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.isSpace) {
|
||||
setActiveSpace(room.id);
|
||||
return;
|
||||
}
|
||||
// Share content into this room
|
||||
final shareContent = Matrix.of(context).shareContent;
|
||||
if (shareContent != null) {
|
||||
final shareFile = shareContent.tryGet<MatrixFile>('file');
|
||||
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
|
||||
shareFile != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
files: [shareFile],
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
Matrix.of(context).shareContent = null;
|
||||
} else {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context)!.forward,
|
||||
message: L10n.of(context)!.forwardMessageTo(
|
||||
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
|
||||
),
|
||||
okLabel: L10n.of(context)!.forward,
|
||||
cancelLabel: L10n.of(context)!.cancel,
|
||||
);
|
||||
if (consent == OkCancelResult.cancel) {
|
||||
Matrix.of(context).shareContent = null;
|
||||
return;
|
||||
}
|
||||
if (consent == OkCancelResult.ok) {
|
||||
room.sendEvent(shareContent);
|
||||
Matrix.of(context).shareContent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.go('/rooms/${room.id}');
|
||||
}
|
||||
|
||||
bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) {
|
||||
switch (activeFilter) {
|
||||
case ActiveFilter.allChats:
|
||||
return (room) => !room.isSpace;
|
||||
case ActiveFilter.groups:
|
||||
return (room) => !room.isSpace && !room.isDirectChat;
|
||||
return (room) => true;
|
||||
case ActiveFilter.messages:
|
||||
return (room) => !room.isSpace && room.isDirectChat;
|
||||
case ActiveFilter.groups:
|
||||
return (room) => !room.isSpace && !room.isDirectChat;
|
||||
case ActiveFilter.unread:
|
||||
return (room) => room.isUnreadOrInvited;
|
||||
case ActiveFilter.spaces:
|
||||
return (r) => r.isSpace;
|
||||
return (room) => room.isSpace;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,15 +421,11 @@ class ChatListController extends State<ChatList>
|
|||
List<Room> get spaces =>
|
||||
Matrix.of(context).client.rooms.where((r) => r.isSpace).toList();
|
||||
|
||||
final selectedRoomIds = <String>{};
|
||||
|
||||
String? get activeChat => widget.activeChat;
|
||||
|
||||
SelectMode get selectMode => Matrix.of(context).shareContent != null
|
||||
? SelectMode.share
|
||||
: selectedRoomIds.isEmpty
|
||||
? SelectMode.normal
|
||||
: SelectMode.select;
|
||||
: SelectMode.normal;
|
||||
|
||||
void _processIncomingSharedFiles(List<SharedMediaFile> files) {
|
||||
if (files.isEmpty) return;
|
||||
|
|
@ -426,6 +512,7 @@ class ChatListController extends State<ChatList>
|
|||
searchServer =
|
||||
Matrix.of(context).store.getString(_serverStoreNamespace);
|
||||
Matrix.of(context).backgroundPush?.setupPush();
|
||||
UpdateNotifier.showUpdateSnackBar(context);
|
||||
}
|
||||
|
||||
// Workaround for system UI overlay style not applied on app start
|
||||
|
|
@ -448,80 +535,195 @@ class ChatListController extends State<ChatList>
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void toggleSelection(String roomId) {
|
||||
setState(
|
||||
() => selectedRoomIds.contains(roomId)
|
||||
? selectedRoomIds.remove(roomId)
|
||||
: selectedRoomIds.add(roomId),
|
||||
);
|
||||
}
|
||||
void chatContextAction(
|
||||
Room room,
|
||||
BuildContext posContext, [
|
||||
Room? space,
|
||||
]) async {
|
||||
if (room.membership == Membership.invite) {
|
||||
return onChatTap(room);
|
||||
}
|
||||
|
||||
Future<void> toggleUnread() async {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final markUnread = anySelectedRoomNotMarkedUnread;
|
||||
final client = Matrix.of(context).client;
|
||||
for (final roomId in selectedRoomIds) {
|
||||
final room = client.getRoomById(roomId)!;
|
||||
if (room.markedUnread == markUnread) continue;
|
||||
await client.getRoomById(roomId)!.markUnread(markUnread);
|
||||
}
|
||||
},
|
||||
);
|
||||
cancelAction();
|
||||
}
|
||||
final overlay =
|
||||
Overlay.of(posContext).context.findRenderObject() as RenderBox;
|
||||
|
||||
Future<void> toggleFavouriteRoom() async {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final makeFavorite = anySelectedRoomNotFavorite;
|
||||
final client = Matrix.of(context).client;
|
||||
for (final roomId in selectedRoomIds) {
|
||||
final room = client.getRoomById(roomId)!;
|
||||
if (room.isFavourite == makeFavorite) continue;
|
||||
await client.getRoomById(roomId)!.setFavourite(makeFavorite);
|
||||
}
|
||||
},
|
||||
);
|
||||
cancelAction();
|
||||
}
|
||||
final button = posContext.findRenderObject() as RenderBox;
|
||||
|
||||
Future<void> toggleMuted() async {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final newState = anySelectedRoomNotMuted
|
||||
? PushRuleState.mentionsOnly
|
||||
: PushRuleState.notify;
|
||||
final client = Matrix.of(context).client;
|
||||
for (final roomId in selectedRoomIds) {
|
||||
final room = client.getRoomById(roomId)!;
|
||||
if (room.pushRuleState == newState) continue;
|
||||
await client.getRoomById(roomId)!.setPushRuleState(newState);
|
||||
}
|
||||
},
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
|
||||
button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
|
||||
ancestor: overlay,
|
||||
),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
cancelAction();
|
||||
}
|
||||
|
||||
Future<void> archiveAction() async {
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
final displayname =
|
||||
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!));
|
||||
|
||||
final action = await showMenu<ChatContextAction>(
|
||||
context: posContext,
|
||||
position: position,
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.open,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: room.avatar,
|
||||
size: Avatar.defaultSize / 2,
|
||||
name: displayname,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
displayname,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
if (space != null)
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.goToSpace,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: space.avatar,
|
||||
size: Avatar.defaultSize / 2,
|
||||
name: space.getLocalizedDisplayname(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
L10n.of(context)!
|
||||
.goToSpace(space.getLocalizedDisplayname()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.mute,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_off,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? L10n.of(context)!.muteChat
|
||||
: L10n.of(context)!.unmuteChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.markUnread,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.markedUnread
|
||||
? L10n.of(context)!.markAsRead
|
||||
: L10n.of(context)!.markAsUnread,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.favorite,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.isFavourite
|
||||
? L10n.of(context)!.unpin
|
||||
: L10n.of(context)!.pin,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChatContextAction.leave,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.delete_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context)!.leave),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (action == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
switch (action) {
|
||||
case ChatContextAction.open:
|
||||
onChatTap(room);
|
||||
return;
|
||||
case ChatContextAction.goToSpace:
|
||||
setActiveSpace(space!.id);
|
||||
return;
|
||||
case ChatContextAction.favorite:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.setFavourite(!room.isFavourite),
|
||||
);
|
||||
return;
|
||||
case ChatContextAction.markUnread:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.markUnread(!room.markedUnread),
|
||||
);
|
||||
return;
|
||||
case ChatContextAction.mute:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => room.setPushRuleState(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? PushRuleState.mentionsOnly
|
||||
: PushRuleState.notify,
|
||||
),
|
||||
);
|
||||
return;
|
||||
case ChatContextAction.leave:
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context)!.areYouSure,
|
||||
okLabel: L10n.of(context)!.yes,
|
||||
cancelLabel: L10n.of(context)!.cancel,
|
||||
okLabel: L10n.of(context)!.leave,
|
||||
cancelLabel: L10n.of(context)!.no,
|
||||
message: L10n.of(context)!.archiveRoomDescription,
|
||||
) ==
|
||||
OkCancelResult.ok;
|
||||
if (!confirmed) return;
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => _archiveSelectedRooms(),
|
||||
);
|
||||
setState(() {});
|
||||
isDestructiveAction: true,
|
||||
);
|
||||
if (confirmed == OkCancelResult.cancel) return;
|
||||
if (!mounted) return;
|
||||
|
||||
await showFutureLoadingDialog(context: context, future: room.leave);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void dismissStatusList() async {
|
||||
|
|
@ -568,76 +770,6 @@ class ChatListController extends State<ChatList>
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveSelectedRooms() async {
|
||||
final client = Matrix.of(context).client;
|
||||
while (selectedRoomIds.isNotEmpty) {
|
||||
final roomId = selectedRoomIds.first;
|
||||
try {
|
||||
await client.getRoomById(roomId)!.leave();
|
||||
} finally {
|
||||
toggleSelection(roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addToSpace() async {
|
||||
final selectedSpace = await showConfirmationDialog<String>(
|
||||
context: context,
|
||||
title: L10n.of(context)!.addToSpace,
|
||||
message: L10n.of(context)!.addToSpaceDescription,
|
||||
fullyCapitalizedForMaterial: false,
|
||||
actions: Matrix.of(context)
|
||||
.client
|
||||
.rooms
|
||||
.where((r) => r.isSpace)
|
||||
.map(
|
||||
(space) => AlertDialogAction(
|
||||
key: space.id,
|
||||
label: space
|
||||
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
if (selectedSpace == null) return;
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final space = Matrix.of(context).client.getRoomById(selectedSpace)!;
|
||||
if (space.canSendDefaultStates) {
|
||||
for (final roomId in selectedRoomIds) {
|
||||
await space.setSpaceChild(roomId);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result.error == null) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => selectedRoomIds.clear());
|
||||
}
|
||||
|
||||
bool get anySelectedRoomNotMarkedUnread => selectedRoomIds.any(
|
||||
(roomId) =>
|
||||
!Matrix.of(context).client.getRoomById(roomId)!.markedUnread,
|
||||
);
|
||||
|
||||
bool get anySelectedRoomNotFavorite => selectedRoomIds.any(
|
||||
(roomId) => !Matrix.of(context).client.getRoomById(roomId)!.isFavourite,
|
||||
);
|
||||
|
||||
bool get anySelectedRoomNotMuted => selectedRoomIds.any(
|
||||
(roomId) =>
|
||||
Matrix.of(context).client.getRoomById(roomId)!.pushRuleState ==
|
||||
PushRuleState.notify,
|
||||
);
|
||||
|
||||
bool waitForFirstSync = false;
|
||||
|
||||
Future<void> _waitForFirstSync() async {
|
||||
|
|
@ -666,19 +798,20 @@ class ChatListController extends State<ChatList>
|
|||
void cancelAction() {
|
||||
if (selectMode == SelectMode.share) {
|
||||
setState(() => Matrix.of(context).shareContent = null);
|
||||
} else {
|
||||
setState(() => selectedRoomIds.clear());
|
||||
}
|
||||
}
|
||||
|
||||
void setActiveFilter(ActiveFilter filter) {
|
||||
setState(() {
|
||||
activeFilter = filter;
|
||||
});
|
||||
}
|
||||
|
||||
void setActiveClient(Client client) {
|
||||
context.go('/rooms');
|
||||
setState(() {
|
||||
activeFilter = AppConfig.separateChatTypes
|
||||
? ActiveFilter.messages
|
||||
: ActiveFilter.allChats;
|
||||
activeSpaceId = null;
|
||||
selectedRoomIds.clear();
|
||||
activeFilter = ActiveFilter.allChats;
|
||||
_activeSpaceId = null;
|
||||
Matrix.of(context).setActiveClient(client);
|
||||
});
|
||||
_clientStream.add(client);
|
||||
|
|
@ -687,7 +820,7 @@ class ChatListController extends State<ChatList>
|
|||
void setActiveBundle(String bundle) {
|
||||
context.go('/rooms');
|
||||
setState(() {
|
||||
selectedRoomIds.clear();
|
||||
_activeSpaceId = null;
|
||||
Matrix.of(context).activeBundle = bundle;
|
||||
if (!Matrix.of(context)
|
||||
.currentBundle!
|
||||
|
|
@ -780,3 +913,18 @@ class ChatListController extends State<ChatList>
|
|||
}
|
||||
|
||||
enum EditBundleAction { addToBundle, removeFromBundle }
|
||||
|
||||
enum InviteActions {
|
||||
accept,
|
||||
decline,
|
||||
block,
|
||||
}
|
||||
|
||||
enum ChatContextAction {
|
||||
open,
|
||||
goToSpace,
|
||||
favorite,
|
||||
markUnread,
|
||||
mute,
|
||||
leave,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
|
|
@ -11,11 +10,11 @@ import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
|
|||
import 'package:fluffychat/pages/chat_list/search_title.dart';
|
||||
import 'package:fluffychat/pages/chat_list/space_view.dart';
|
||||
import 'package:fluffychat/pages/chat_list/status_msg_list.dart';
|
||||
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
|
||||
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
|
||||
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:fluffychat/utils/stream_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/hover_builder.dart';
|
||||
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
|
||||
import '../../config/themes.dart';
|
||||
import '../../widgets/connection_status_header.dart';
|
||||
|
|
@ -29,6 +28,29 @@ class ChatListViewBody extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
final activeSpace = controller.activeSpaceId;
|
||||
if (activeSpace != null) {
|
||||
return SpaceView(
|
||||
spaceId: activeSpace,
|
||||
onBack: controller.clearActiveSpace,
|
||||
onChatTab: (room) => controller.onChatTap(room),
|
||||
onChatContext: (room, context) =>
|
||||
controller.chatContextAction(room, context),
|
||||
activeChat: controller.activeChat,
|
||||
toParentSpace: controller.setActiveSpace,
|
||||
);
|
||||
}
|
||||
final spaces = client.rooms.where((r) => r.isSpace);
|
||||
final spaceDelegateCandidates = <String, Room>{};
|
||||
for (final space in spaces) {
|
||||
for (final spaceChild in space.spaceChildren) {
|
||||
final roomId = spaceChild.roomId;
|
||||
if (roomId == null) continue;
|
||||
spaceDelegateCandidates[roomId] = space;
|
||||
}
|
||||
}
|
||||
|
||||
final publicRooms = controller.roomSearchResult?.chunk
|
||||
.where((room) => room.roomType != 'm.space')
|
||||
.toList();
|
||||
|
|
@ -36,231 +58,283 @@ class ChatListViewBody extends StatelessWidget {
|
|||
.where((room) => room.roomType == 'm.space')
|
||||
.toList();
|
||||
final userSearchResult = controller.userSearchResult;
|
||||
final client = Matrix.of(context).client;
|
||||
const dummyChatCount = 4;
|
||||
final titleColor =
|
||||
Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(100);
|
||||
final subtitleColor =
|
||||
Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50);
|
||||
final filter = controller.searchController.text.toLowerCase();
|
||||
return PageTransitionSwitcher(
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.vertical,
|
||||
fillColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: StreamBuilder(
|
||||
key: ValueKey(
|
||||
client.userID.toString() +
|
||||
controller.activeFilter.toString() +
|
||||
controller.activeSpaceId.toString(),
|
||||
),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
if (controller.activeFilter == ActiveFilter.spaces) {
|
||||
return SpaceView(
|
||||
controller,
|
||||
scrollController: controller.scrollController,
|
||||
key: Key(controller.activeSpaceId ?? 'Spaces'),
|
||||
);
|
||||
}
|
||||
final rooms = controller.filteredRooms;
|
||||
return SafeArea(
|
||||
child: CustomScrollView(
|
||||
controller: controller.scrollController,
|
||||
slivers: [
|
||||
ChatListHeader(controller: controller),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
if (controller.isSearchMode) ...[
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.publicRooms,
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
return StreamBuilder(
|
||||
key: ValueKey(
|
||||
client.userID.toString(),
|
||||
),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final rooms = controller.filteredRooms;
|
||||
|
||||
return SafeArea(
|
||||
child: CustomScrollView(
|
||||
controller: controller.scrollController,
|
||||
slivers: [
|
||||
ChatListHeader(controller: controller),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
if (controller.isSearchMode) ...[
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.publicRooms,
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicRooms),
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.publicSpaces,
|
||||
icon: const Icon(Icons.workspaces_outlined),
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicSpaces),
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.users,
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
),
|
||||
AnimatedContainer(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height: userSearchResult == null ||
|
||||
userSearchResult.results.isEmpty
|
||||
? 0
|
||||
: 106,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: userSearchResult == null
|
||||
? null
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: userSearchResult.results.length,
|
||||
itemBuilder: (context, i) => _SearchItem(
|
||||
title:
|
||||
userSearchResult.results[i].displayName ??
|
||||
userSearchResult
|
||||
.results[i].userId.localpart ??
|
||||
L10n.of(context)!.unknownDevice,
|
||||
avatar: userSearchResult.results[i].avatarUrl,
|
||||
onPressed: () => showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (c) => UserBottomSheet(
|
||||
profile: userSearchResult.results[i],
|
||||
outerContext: context,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!controller.isSearchMode && AppConfig.showPresences)
|
||||
GestureDetector(
|
||||
onLongPress: () => controller.dismissStatusList(),
|
||||
child: StatusMessageList(
|
||||
onStatusEdit: controller.setStatus,
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicRooms),
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.publicSpaces,
|
||||
icon: const Icon(Icons.workspaces_outlined),
|
||||
),
|
||||
const ConnectionStatusHeader(),
|
||||
AnimatedContainer(
|
||||
height: controller.isTorBrowser ? 64 : 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.vpn_key),
|
||||
title: Text(L10n.of(context)!.dehydrateTor),
|
||||
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
onTap: controller.dehydrate,
|
||||
),
|
||||
PublicRoomsHorizontalList(publicRooms: publicSpaces),
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.users,
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
),
|
||||
AnimatedContainer(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height: userSearchResult == null ||
|
||||
userSearchResult.results.isEmpty
|
||||
? 0
|
||||
: 106,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: userSearchResult == null
|
||||
? null
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: userSearchResult.results.length,
|
||||
itemBuilder: (context, i) => _SearchItem(
|
||||
title: userSearchResult
|
||||
.results[i].displayName ??
|
||||
userSearchResult
|
||||
.results[i].userId.localpart ??
|
||||
L10n.of(context)!.unknownDevice,
|
||||
avatar:
|
||||
userSearchResult.results[i].avatarUrl,
|
||||
onPressed: () => showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (c) => UserBottomSheet(
|
||||
profile: userSearchResult.results[i],
|
||||
outerContext: context,
|
||||
),
|
||||
),
|
||||
if (client.rooms.isNotEmpty && !controller.isSearchMode)
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 6,
|
||||
),
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
if (AppConfig.separateChatTypes)
|
||||
ActiveFilter.messages
|
||||
else
|
||||
ActiveFilter.allChats,
|
||||
ActiveFilter.groups,
|
||||
ActiveFilter.unread,
|
||||
if (spaceDelegateCandidates.isNotEmpty &&
|
||||
!controller.widget.displayNavigationRail)
|
||||
ActiveFilter.spaces,
|
||||
]
|
||||
.map(
|
||||
(filter) => Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: HoverBuilder(
|
||||
builder: (context, hovered) =>
|
||||
AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: hovered ? 1.1 : 1.0,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
onTap: () =>
|
||||
controller.setActiveFilter(filter),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: filter ==
|
||||
controller.activeFilter
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
filter.toLocalizedString(context),
|
||||
style: TextStyle(
|
||||
fontWeight: filter ==
|
||||
controller.activeFilter
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
color: filter ==
|
||||
controller.activeFilter
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!controller.isSearchMode &&
|
||||
controller.activeFilter != ActiveFilter.groups &&
|
||||
AppConfig.showPresences)
|
||||
GestureDetector(
|
||||
onLongPress: () => controller.dismissStatusList(),
|
||||
child: StatusMessageList(
|
||||
onStatusEdit: controller.setStatus,
|
||||
),
|
||||
),
|
||||
const ConnectionStatusHeader(),
|
||||
AnimatedContainer(
|
||||
height: controller.isTorBrowser ? 64 : 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.vpn_key),
|
||||
title: Text(L10n.of(context)!.dehydrateTor),
|
||||
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
onTap: controller.dehydrate,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (controller.isSearchMode)
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.chats,
|
||||
icon: const Icon(Icons.forum_outlined),
|
||||
if (controller.isSearchMode)
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.chats,
|
||||
icon: const Icon(Icons.forum_outlined),
|
||||
),
|
||||
if (client.prevBatch != null &&
|
||||
rooms.isEmpty &&
|
||||
!controller.isSearchMode) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Icon(
|
||||
CupertinoIcons.chat_bubble_2,
|
||||
size: 128,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
if (client.prevBatch != null &&
|
||||
rooms.isEmpty &&
|
||||
!controller.isSearchMode) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Icon(
|
||||
CupertinoIcons.chat_bubble_2,
|
||||
size: 128,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onInverseSurface,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (client.prevBatch == null)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => Opacity(
|
||||
opacity: (dummyChatCount - i) / dummyChatCount,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: titleColor,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
color: Theme.of(context).textTheme.bodyLarge!.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: titleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 36),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
height: 12,
|
||||
margin: const EdgeInsets.only(right: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
childCount: dummyChatCount,
|
||||
),
|
||||
),
|
||||
if (client.prevBatch == null)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => Opacity(
|
||||
opacity: (dummyChatCount - i) / dummyChatCount,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: titleColor,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
color:
|
||||
Theme.of(context).textTheme.bodyLarge!.color,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: titleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 36),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: subtitleColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
height: 12,
|
||||
margin: const EdgeInsets.only(right: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
childCount: dummyChatCount,
|
||||
),
|
||||
),
|
||||
if (client.prevBatch != null)
|
||||
SliverList.builder(
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
return ChatListItem(
|
||||
rooms[i],
|
||||
key: Key('chat_list_item_${rooms[i].id}'),
|
||||
filter: filter,
|
||||
selected:
|
||||
controller.selectedRoomIds.contains(rooms[i].id),
|
||||
onTap: controller.selectMode == SelectMode.select
|
||||
? () => controller.toggleSelection(rooms[i].id)
|
||||
: () => onChatTap(rooms[i], context),
|
||||
onLongPress: () =>
|
||||
controller.toggleSelection(rooms[i].id),
|
||||
activeChat: controller.activeChat == rooms[i].id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (client.prevBatch != null)
|
||||
SliverList.builder(
|
||||
itemCount: rooms.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
final room = rooms[i];
|
||||
final space = spaceDelegateCandidates[room.id];
|
||||
return ChatListItem(
|
||||
room,
|
||||
space: space,
|
||||
key: Key('chat_list_item_${room.id}'),
|
||||
filter: filter,
|
||||
onTap: () => controller.onChatTap(room),
|
||||
onLongPress: (context) =>
|
||||
controller.chatContextAction(room, context, space),
|
||||
activeChat: controller.activeChat == room.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,88 +43,77 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
|
|||
L10n.of(context)!.share,
|
||||
key: const ValueKey(SelectMode.share),
|
||||
)
|
||||
: selectMode == SelectMode.select
|
||||
? Text(
|
||||
controller.selectedRoomIds.length.toString(),
|
||||
key: const ValueKey(SelectMode.select),
|
||||
)
|
||||
: TextField(
|
||||
controller: controller.searchController,
|
||||
focusNode: controller.searchFocusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: (text) => controller.onSearchEnter(
|
||||
text,
|
||||
globalSearch: globalSearch,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
fillColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: L10n.of(context)!.searchChatsRooms,
|
||||
hintStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
prefixIcon: controller.isSearchMode
|
||||
? IconButton(
|
||||
tooltip: L10n.of(context)!.cancel,
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
onPressed: controller.cancelSearch,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: controller.startSearch,
|
||||
icon: Icon(
|
||||
Icons.search_outlined,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
suffixIcon: controller.isSearchMode && globalSearch
|
||||
? controller.isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10.0,
|
||||
horizontal: 12,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: controller.setServer,
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: Text(
|
||||
controller.searchServer ??
|
||||
Matrix.of(context)
|
||||
.client
|
||||
.homeserver!
|
||||
.host,
|
||||
maxLines: 2,
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 0,
|
||||
child: ClientChooserButton(controller),
|
||||
),
|
||||
),
|
||||
: TextField(
|
||||
controller: controller.searchController,
|
||||
focusNode: controller.searchFocusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: (text) => controller.onSearchEnter(
|
||||
text,
|
||||
globalSearch: globalSearch,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
fillColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: L10n.of(context)!.searchChatsRooms,
|
||||
hintStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
prefixIcon: controller.isSearchMode
|
||||
? IconButton(
|
||||
tooltip: L10n.of(context)!.cancel,
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
onPressed: controller.cancelSearch,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: controller.startSearch,
|
||||
icon: Icon(
|
||||
Icons.search_outlined,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
suffixIcon: controller.isSearchMode && globalSearch
|
||||
? controller.isSearching
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10.0,
|
||||
horizontal: 12,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: controller.setServer,
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: Text(
|
||||
controller.searchServer ??
|
||||
Matrix.of(context).client.homeserver!.host,
|
||||
maxLines: 2,
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 0,
|
||||
child: ClientChooserButton(controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: selectMode == SelectMode.share
|
||||
? [
|
||||
Padding(
|
||||
|
|
@ -135,48 +124,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
|
|||
child: ClientChooserButton(controller),
|
||||
),
|
||||
]
|
||||
: selectMode == SelectMode.select
|
||||
? [
|
||||
if (controller.spaces.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: L10n.of(context)!.addToSpace,
|
||||
icon: const Icon(Icons.workspaces_outlined),
|
||||
onPressed: controller.addToSpace,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: L10n.of(context)!.toggleUnread,
|
||||
icon: Icon(
|
||||
controller.anySelectedRoomNotMarkedUnread
|
||||
? Icons.mark_chat_unread_outlined
|
||||
: Icons.mark_chat_read_outlined,
|
||||
),
|
||||
onPressed: controller.toggleUnread,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: L10n.of(context)!.toggleFavorite,
|
||||
icon: Icon(
|
||||
controller.anySelectedRoomNotFavorite
|
||||
? Icons.push_pin
|
||||
: Icons.push_pin_outlined,
|
||||
),
|
||||
onPressed: controller.toggleFavouriteRoom,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
controller.anySelectedRoomNotMuted
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_outlined,
|
||||
),
|
||||
tooltip: L10n.of(context)!.toggleMuted,
|
||||
onPressed: controller.toggleMuted,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
tooltip: L10n.of(context)!.archive,
|
||||
onPressed: controller.archiveAction,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ enum ArchivedRoomAction { delete, rejoin }
|
|||
|
||||
class ChatListItem extends StatelessWidget {
|
||||
final Room room;
|
||||
final Room? space;
|
||||
final bool activeChat;
|
||||
final bool selected;
|
||||
final void Function()? onLongPress;
|
||||
final void Function(BuildContext context)? onLongPress;
|
||||
final void Function()? onForget;
|
||||
final void Function() onTap;
|
||||
final String? filter;
|
||||
|
|
@ -27,11 +27,11 @@ class ChatListItem extends StatelessWidget {
|
|||
const ChatListItem(
|
||||
this.room, {
|
||||
this.activeChat = false,
|
||||
this.selected = false,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
this.onForget,
|
||||
this.filter,
|
||||
this.space,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -77,11 +77,8 @@ class ChatListItem extends StatelessWidget {
|
|||
: 14.0
|
||||
: 0.0;
|
||||
final hasNotifications = room.notificationCount > 0;
|
||||
final backgroundColor = selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: activeChat
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: null;
|
||||
final backgroundColor =
|
||||
activeChat ? theme.colorScheme.secondaryContainer : null;
|
||||
final displayname = room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
);
|
||||
|
|
@ -93,6 +90,7 @@ class ChatListItem extends StatelessWidget {
|
|||
final needLastEventSender = lastEvent == null
|
||||
? false
|
||||
: room.getState(EventTypes.RoomMember, lastEvent.senderId) == null;
|
||||
final space = this.space;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
|
@ -106,47 +104,89 @@ class ChatListItem extends StatelessWidget {
|
|||
child: FutureBuilder(
|
||||
future: room.loadHeroUsers(),
|
||||
builder: (context, snapshot) => HoverBuilder(
|
||||
builder: (context, hovered) => ListTile(
|
||||
builder: (context, listTileHovered) => ListTile(
|
||||
visualDensity: const VisualDensity(vertical: -0.5),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onLongPress: onLongPress,
|
||||
leading: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
HoverBuilder(
|
||||
builder: (context, hovered) => AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: hovered ? 1.1 : 1.0,
|
||||
child: Avatar(
|
||||
mxContent: room.avatar,
|
||||
name: displayname,
|
||||
presenceUserId: directChatMatrixId,
|
||||
presenceBackgroundColor: backgroundColor,
|
||||
onTap: onLongPress,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: (hovered || selected) ? 1.0 : 0.0,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Icon(
|
||||
selected
|
||||
? Icons.check_circle
|
||||
: Icons.check_circle_outlined,
|
||||
size: 18,
|
||||
onLongPress: () => onLongPress?.call(context),
|
||||
leading: HoverBuilder(
|
||||
builder: (context, hovered) => AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: hovered ? 1.1 : 1.0,
|
||||
child: SizedBox(
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (space != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Avatar(
|
||||
border: BorderSide(
|
||||
width: 2,
|
||||
color: backgroundColor ??
|
||||
Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 4,
|
||||
),
|
||||
mxContent: space.avatar,
|
||||
size: Avatar.defaultSize * 0.75,
|
||||
name: space.getLocalizedDisplayname(),
|
||||
onTap: () => onLongPress?.call(context),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Avatar(
|
||||
border: space == null
|
||||
? null
|
||||
: BorderSide(
|
||||
width: 2,
|
||||
color: backgroundColor ??
|
||||
Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
borderRadius: room.isSpace
|
||||
? BorderRadius.circular(
|
||||
AppConfig.borderRadius / 4,
|
||||
)
|
||||
: null,
|
||||
mxContent: room.avatar,
|
||||
size: space != null
|
||||
? Avatar.defaultSize * 0.75
|
||||
: Avatar.defaultSize,
|
||||
name: displayname,
|
||||
presenceUserId: directChatMatrixId,
|
||||
presenceBackgroundColor: backgroundColor,
|
||||
onTap: () => onLongPress?.call(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => onLongPress?.call(context),
|
||||
child: AnimatedScale(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
scale: listTileHovered ? 1.0 : 0.0,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Icon(
|
||||
Icons.arrow_drop_down_circle_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: <Widget>[
|
||||
|
|
@ -180,7 +220,9 @@ class ChatListItem extends StatelessWidget {
|
|||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (lastEvent != null && room.membership != Membership.invite)
|
||||
if (!room.isSpace &&
|
||||
lastEvent != null &&
|
||||
room.membership != Membership.invite)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: Text(
|
||||
|
|
@ -193,6 +235,11 @@ class ChatListItem extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (room.isSpace)
|
||||
const Icon(
|
||||
Icons.arrow_circle_right_outlined,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
|
|
@ -222,62 +269,70 @@ class ChatListItem extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Expanded(
|
||||
child: typingText.isNotEmpty
|
||||
child: room.isSpace && room.membership == Membership.join
|
||||
? Text(
|
||||
typingText,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
L10n.of(context)!.countChatsAndCountParticipants(
|
||||
room.spaceChildren.length.toString(),
|
||||
(room.summary.mJoinedMemberCount ?? 1).toString(),
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: FutureBuilder(
|
||||
key: ValueKey(
|
||||
'${lastEvent?.eventId}_${lastEvent?.type}',
|
||||
),
|
||||
future: needLastEventSender
|
||||
? lastEvent.calcLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix: !isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId,
|
||||
)
|
||||
: null,
|
||||
initialData: lastEvent?.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix: !isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId,
|
||||
),
|
||||
builder: (context, snapshot) => Text(
|
||||
room.membership == Membership.invite
|
||||
? isDirectChat
|
||||
? L10n.of(context)!.invitePrivateChat
|
||||
: L10n.of(context)!.inviteGroupChat
|
||||
: snapshot.data ??
|
||||
L10n.of(context)!.emptyChat,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: unread || room.hasNewMessages
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
decoration: room.lastEvent?.redacted == true
|
||||
? TextDecoration.lineThrough
|
||||
: typingText.isNotEmpty
|
||||
? Text(
|
||||
typingText,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
)
|
||||
: FutureBuilder(
|
||||
key: ValueKey(
|
||||
'${lastEvent?.eventId}_${lastEvent?.type}',
|
||||
),
|
||||
future: needLastEventSender
|
||||
? lastEvent.calcLocalizedBody(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix: (!isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId),
|
||||
)
|
||||
: null,
|
||||
initialData:
|
||||
lastEvent?.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
hideReply: true,
|
||||
hideEdit: true,
|
||||
plaintextBody: true,
|
||||
removeMarkdown: true,
|
||||
withSenderNamePrefix: (!isDirectChat ||
|
||||
directChatMatrixId !=
|
||||
room.lastEvent?.senderId),
|
||||
),
|
||||
builder: (context, snapshot) => Text(
|
||||
room.membership == Membership.invite
|
||||
? isDirectChat
|
||||
? L10n.of(context)!.invitePrivateChat
|
||||
: L10n.of(context)!.inviteGroupChat
|
||||
: snapshot.data ??
|
||||
L10n.of(context)!.emptyChat,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: unread || room.hasNewMessages
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
decoration: room.lastEvent?.redacted == true
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedContainer(
|
||||
|
|
|
|||
|
|
@ -1,84 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/chat_list/chat_list.dart';
|
||||
import 'package:fluffychat/pages/chat_list/navi_rail_item.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/stream_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'chat_list_body.dart';
|
||||
import 'start_chat_fab.dart';
|
||||
|
||||
class ChatListView extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ChatListView(this.controller, {super.key});
|
||||
|
||||
List<NavigationDestination> getNavigationDestinations(BuildContext context) {
|
||||
final badgePosition = BadgePosition.topEnd(top: -12, end: -8);
|
||||
return [
|
||||
if (AppConfig.separateChatTypes) ...[
|
||||
NavigationDestination(
|
||||
icon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter:
|
||||
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
|
||||
child: const Icon(Icons.chat_outlined),
|
||||
),
|
||||
selectedIcon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter:
|
||||
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
|
||||
child: const Icon(Icons.chat),
|
||||
),
|
||||
label: L10n.of(context)!.messages,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
|
||||
child: const Icon(Icons.group_outlined),
|
||||
),
|
||||
selectedIcon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
|
||||
child: const Icon(Icons.group),
|
||||
),
|
||||
label: L10n.of(context)!.groups,
|
||||
),
|
||||
] else
|
||||
NavigationDestination(
|
||||
icon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter:
|
||||
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
|
||||
child: const Icon(Icons.chat_outlined),
|
||||
),
|
||||
selectedIcon: UnreadRoomsBadge(
|
||||
badgePosition: badgePosition,
|
||||
filter:
|
||||
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
|
||||
child: const Icon(Icons.chat),
|
||||
),
|
||||
label: L10n.of(context)!.chats,
|
||||
),
|
||||
if (controller.spaces.isNotEmpty)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.workspaces_outlined),
|
||||
selectedIcon: Icon(Icons.workspaces),
|
||||
label: 'Spaces',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
|
|
@ -89,12 +31,13 @@ class ChatListView extends StatelessWidget {
|
|||
return PopScope(
|
||||
canPop: controller.selectMode == SelectMode.normal &&
|
||||
!controller.isSearchMode &&
|
||||
controller.activeFilter ==
|
||||
(AppConfig.separateChatTypes
|
||||
? ActiveFilter.messages
|
||||
: ActiveFilter.allChats),
|
||||
onPopInvoked: (pop) async {
|
||||
controller.activeSpaceId == null,
|
||||
onPopInvoked: (pop) {
|
||||
if (pop) return;
|
||||
if (controller.activeSpaceId != null) {
|
||||
controller.clearActiveSpace();
|
||||
return;
|
||||
}
|
||||
final selMode = controller.selectMode;
|
||||
if (controller.isSearchMode) {
|
||||
controller.cancelSearch();
|
||||
|
|
@ -104,23 +47,23 @@ class ChatListView extends StatelessWidget {
|
|||
controller.cancelAction();
|
||||
return;
|
||||
}
|
||||
if (controller.activeFilter !=
|
||||
(AppConfig.separateChatTypes
|
||||
? ActiveFilter.messages
|
||||
: ActiveFilter.allChats)) {
|
||||
controller
|
||||
.onDestinationSelected(AppConfig.separateChatTypes ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
if (FluffyThemes.isColumnMode(context) &&
|
||||
controller.widget.displayNavigationRail) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final allSpaces =
|
||||
client.rooms.where((room) => room.isSpace);
|
||||
StreamBuilder(
|
||||
key: ValueKey(
|
||||
client.userID.toString(),
|
||||
),
|
||||
stream: client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, _) {
|
||||
final allSpaces = Matrix.of(context)
|
||||
.client
|
||||
.rooms
|
||||
.where((room) => room.isSpace);
|
||||
final rootSpaces = allSpaces
|
||||
.where(
|
||||
(space) => !allSpaces.any(
|
||||
|
|
@ -129,40 +72,53 @@ class ChatListView extends StatelessWidget {
|
|||
),
|
||||
)
|
||||
.toList();
|
||||
final destinations = getNavigationDestinations(context);
|
||||
|
||||
return SizedBox(
|
||||
width: FluffyThemes.navRailWidth,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.vertical,
|
||||
itemCount: rootSpaces.length + destinations.length,
|
||||
itemCount: rootSpaces.length + 2,
|
||||
itemBuilder: (context, i) {
|
||||
if (i < destinations.length) {
|
||||
if (i == 0) {
|
||||
return NaviRailItem(
|
||||
isSelected: i == controller.selectedIndex,
|
||||
onTap: () => controller.onDestinationSelected(i),
|
||||
icon: destinations[i].icon,
|
||||
selectedIcon: destinations[i].selectedIcon,
|
||||
toolTip: destinations[i].label,
|
||||
isSelected: controller.activeSpaceId == null,
|
||||
onTap: controller.clearActiveSpace,
|
||||
icon: const Icon(Icons.forum_outlined),
|
||||
selectedIcon: const Icon(Icons.forum),
|
||||
toolTip: L10n.of(context)!.chats,
|
||||
unreadBadgeFilter: (room) => true,
|
||||
);
|
||||
}
|
||||
i -= destinations.length;
|
||||
final isSelected =
|
||||
controller.activeFilter == ActiveFilter.spaces &&
|
||||
rootSpaces[i].id == controller.activeSpaceId;
|
||||
i--;
|
||||
if (i == rootSpaces.length) {
|
||||
return NaviRailItem(
|
||||
isSelected: false,
|
||||
onTap: () => context.go('/rooms/newspace'),
|
||||
icon: const Icon(Icons.add),
|
||||
toolTip: L10n.of(context)!.createNewSpace,
|
||||
);
|
||||
}
|
||||
final space = rootSpaces[i];
|
||||
final displayname =
|
||||
rootSpaces[i].getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
);
|
||||
final spaceChildrenIds =
|
||||
space.spaceChildren.map((c) => c.roomId).toSet();
|
||||
return NaviRailItem(
|
||||
toolTip: rootSpaces[i].getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
),
|
||||
isSelected: isSelected,
|
||||
toolTip: displayname,
|
||||
isSelected: controller.activeSpaceId == space.id,
|
||||
onTap: () =>
|
||||
controller.setActiveSpace(rootSpaces[i].id),
|
||||
unreadBadgeFilter: (room) =>
|
||||
spaceChildrenIds.contains(room.id),
|
||||
icon: Avatar(
|
||||
mxContent: rootSpaces[i].avatar,
|
||||
name: rootSpaces[i].getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
),
|
||||
name: displayname,
|
||||
size: 32,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 4,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -182,23 +138,6 @@ class ChatListView extends StatelessWidget {
|
|||
behavior: HitTestBehavior.translucent,
|
||||
child: Scaffold(
|
||||
body: ChatListViewBody(controller),
|
||||
bottomNavigationBar: controller.displayNavigationBar
|
||||
? NavigationBar(
|
||||
elevation: 4,
|
||||
labelBehavior:
|
||||
NavigationDestinationLabelBehavior.alwaysShow,
|
||||
shadowColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surface,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.surface,
|
||||
selectedIndex: controller.selectedIndex,
|
||||
onDestinationSelected:
|
||||
controller.onDestinationSelected,
|
||||
destinations: getNavigationDestinations(context),
|
||||
)
|
||||
: null,
|
||||
floatingActionButton: KeyBoardShortcuts(
|
||||
keysToPress: {
|
||||
LogicalKeyboardKey.controlLeft,
|
||||
|
|
@ -207,12 +146,16 @@ class ChatListView extends StatelessWidget {
|
|||
onKeysPressed: () => context.go('/rooms/newprivatechat'),
|
||||
helpLabel: L10n.of(context)!.newChat,
|
||||
child: selectMode == SelectMode.normal &&
|
||||
!controller.isSearchMode
|
||||
? StartChatFloatingActionButton(
|
||||
activeFilter: controller.activeFilter,
|
||||
roomsIsEmpty: false,
|
||||
scrolledToTop: controller.scrolledToTop,
|
||||
createNewSpace: controller.createNewSpace,
|
||||
!controller.isSearchMode &&
|
||||
controller.activeSpaceId == null
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () =>
|
||||
context.go('/rooms/newprivatechat'),
|
||||
icon: const Icon(Icons.add_outlined),
|
||||
label: Text(
|
||||
L10n.of(context)!.chat,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ class ClientChooserButton extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
// Currently disabled because of:
|
||||
// https://github.com/matrix-org/matrix-react-sdk/pull/12286
|
||||
/*PopupMenuItem(
|
||||
value: SettingsAction.archive,
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -77,7 +79,7 @@ class ClientChooserButton extends StatelessWidget {
|
|||
Text(L10n.of(context)!.archive),
|
||||
],
|
||||
),
|
||||
),
|
||||
),*/
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.settings,
|
||||
child: Row(
|
||||
|
|
|
|||
95
lib/pages/chat_list/nav_rail_item.dart
Normal file
95
lib/pages/chat_list/nav_rail_item.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import '../../config/themes.dart';
|
||||
|
||||
class NaviRailItem extends StatefulWidget {
|
||||
final String toolTip;
|
||||
final bool isSelected;
|
||||
final void Function() onTap;
|
||||
final Widget icon;
|
||||
final Widget? selectedIcon;
|
||||
|
||||
const NaviRailItem({
|
||||
required this.toolTip,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NaviRailItem> createState() => _NaviRailItemState();
|
||||
}
|
||||
|
||||
class _NaviRailItemState extends State<NaviRailItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
void _onHover(bool hover) {
|
||||
if (hover == _hovered) return;
|
||||
setState(() {
|
||||
_hovered = hover;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
width: 64,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
child: AnimatedContainer(
|
||||
width: widget.isSelected ? 4 : 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(90),
|
||||
bottomRight: Radius.circular(90),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
scale: _hovered ? 1.2 : 1.0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: Material(
|
||||
borderRadius: borderRadius,
|
||||
color: widget.isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: Tooltip(
|
||||
message: widget.toolTip,
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: widget.onTap,
|
||||
onHover: _onHover,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: widget.isSelected
|
||||
? widget.selectedIcon ?? widget.icon
|
||||
: widget.icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/widgets/hover_builder.dart';
|
||||
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
|
||||
import '../../config/themes.dart';
|
||||
|
||||
class NaviRailItem extends StatefulWidget {
|
||||
class NaviRailItem extends StatelessWidget {
|
||||
final String toolTip;
|
||||
final bool isSelected;
|
||||
final void Function() onTap;
|
||||
final Widget icon;
|
||||
final Widget? selectedIcon;
|
||||
final bool Function(Room)? unreadBadgeFilter;
|
||||
|
||||
const NaviRailItem({
|
||||
required this.toolTip,
|
||||
|
|
@ -16,80 +22,78 @@ class NaviRailItem extends StatefulWidget {
|
|||
required this.onTap,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
this.unreadBadgeFilter,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NaviRailItem> createState() => _NaviRailItemState();
|
||||
}
|
||||
|
||||
class _NaviRailItemState extends State<NaviRailItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
void _onHover(bool hover) {
|
||||
if (hover == _hovered) return;
|
||||
setState(() {
|
||||
_hovered = hover;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
width: 64,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
child: AnimatedContainer(
|
||||
width: widget.isSelected ? 4 : 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(90),
|
||||
bottomRight: Radius.circular(90),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
scale: _hovered ? 1.2 : 1.0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: Material(
|
||||
borderRadius: borderRadius,
|
||||
color: widget.isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: Tooltip(
|
||||
message: widget.toolTip,
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: widget.onTap,
|
||||
onHover: _onHover,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: widget.isSelected
|
||||
? widget.selectedIcon ?? widget.icon
|
||||
: widget.icon,
|
||||
final icon = isSelected ? selectedIcon ?? this.icon : this.icon;
|
||||
final unreadBadgeFilter = this.unreadBadgeFilter;
|
||||
return HoverBuilder(
|
||||
builder: (context, hovered) {
|
||||
return SizedBox(
|
||||
height: FluffyThemes.navRailWidth,
|
||||
width: FluffyThemes.navRailWidth,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
child: AnimatedContainer(
|
||||
width: isSelected ? 4 : 0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(90),
|
||||
bottomRight: Radius.circular(90),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
scale: hovered ? 1.2 : 1.0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: Material(
|
||||
borderRadius: borderRadius,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
child: Tooltip(
|
||||
message: toolTip,
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: unreadBadgeFilter == null
|
||||
? icon
|
||||
: UnreadRoomsBadge(
|
||||
filter: unreadBadgeFilter,
|
||||
badgePosition: BadgePosition.topEnd(
|
||||
top: -12,
|
||||
end: -8,
|
||||
),
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,34 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:matrix/matrix.dart' as sdk;
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pages/chat_list/chat_list.dart';
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
|
||||
import 'package:fluffychat/pages/chat_list/search_title.dart';
|
||||
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/stream_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import '../../utils/localized_exception_extension.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'chat_list_header.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
|
||||
|
||||
enum AddRoomType { chat, subspace }
|
||||
|
||||
class SpaceView extends StatefulWidget {
|
||||
final ChatListController controller;
|
||||
final ScrollController scrollController;
|
||||
const SpaceView(
|
||||
this.controller, {
|
||||
final String spaceId;
|
||||
final void Function() onBack;
|
||||
final void Function(String spaceId) toParentSpace;
|
||||
final void Function(Room room) onChatTab;
|
||||
final void Function(Room room, BuildContext context) onChatContext;
|
||||
final String? activeChat;
|
||||
|
||||
const SpaceView({
|
||||
required this.spaceId,
|
||||
required this.onBack,
|
||||
required this.onChatTab,
|
||||
required this.activeChat,
|
||||
required this.toParentSpace,
|
||||
required this.onChatContext,
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -32,157 +43,114 @@ class SpaceView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SpaceViewState extends State<SpaceView> {
|
||||
static final Map<String, GetSpaceHierarchyResponse> _lastResponse = {};
|
||||
|
||||
String? prevBatch;
|
||||
Object? error;
|
||||
bool loading = false;
|
||||
final List<SpaceRoomsChunk> _discoveredChildren = [];
|
||||
final TextEditingController _filterController = TextEditingController();
|
||||
String? _nextBatch;
|
||||
bool _noMoreRooms = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
loadHierarchy();
|
||||
_loadHierarchy();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
_lastResponse.remove(widget.controller.activeSpaceId);
|
||||
loadHierarchy();
|
||||
}
|
||||
|
||||
Future<GetSpaceHierarchyResponse?> loadHierarchy([String? prevBatch]) async {
|
||||
final activeSpaceId = widget.controller.activeSpaceId;
|
||||
if (activeSpaceId == null) return null;
|
||||
final client = Matrix.of(context).client;
|
||||
|
||||
final activeSpace = client.getRoomById(activeSpaceId);
|
||||
await activeSpace?.postLoad();
|
||||
void _loadHierarchy() async {
|
||||
final room = Matrix.of(context).client.getRoomById(widget.spaceId);
|
||||
if (room == null) return;
|
||||
|
||||
setState(() {
|
||||
error = null;
|
||||
loading = true;
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await client.getSpaceHierarchy(
|
||||
activeSpaceId,
|
||||
maxDepth: 1,
|
||||
from: prevBatch,
|
||||
final hierarchy = await room.client.getSpaceHierarchy(
|
||||
widget.spaceId,
|
||||
suggestedOnly: false,
|
||||
maxDepth: 2,
|
||||
from: _nextBatch,
|
||||
);
|
||||
|
||||
if (prevBatch != null) {
|
||||
response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_lastResponse[activeSpaceId] = response;
|
||||
_nextBatch = hierarchy.nextBatch;
|
||||
if (hierarchy.nextBatch == null) {
|
||||
_noMoreRooms = true;
|
||||
}
|
||||
_discoveredChildren.addAll(
|
||||
hierarchy.rooms
|
||||
.where((c) => room.client.getRoomById(c.roomId) == null),
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
return _lastResponse[activeSpaceId]!;
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to load hierarchy', e, s);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
setState(() {
|
||||
error = e;
|
||||
});
|
||||
rethrow;
|
||||
} finally {
|
||||
setState(() {
|
||||
loading = false;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async {
|
||||
void _joinChildRoom(SpaceRoomsChunk item) async {
|
||||
final client = Matrix.of(context).client;
|
||||
final space = client.getRoomById(widget.controller.activeSpaceId!);
|
||||
if (client.getRoomById(spaceChild.roomId) == null) {
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
await client.joinRoom(
|
||||
spaceChild.roomId,
|
||||
serverName: space?.spaceChildren
|
||||
.firstWhereOrNull(
|
||||
(child) => child.roomId == spaceChild.roomId,
|
||||
)
|
||||
?.via,
|
||||
);
|
||||
if (client.getRoomById(spaceChild.roomId) == null) {
|
||||
// Wait for room actually appears in sync
|
||||
await client.waitForRoomInSync(spaceChild.roomId, join: true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (result.error != null) return;
|
||||
_refresh();
|
||||
}
|
||||
if (spaceChild.roomType == 'm.space') {
|
||||
if (spaceChild.roomId == widget.controller.activeSpaceId) {
|
||||
context.go('/rooms/${spaceChild.roomId}');
|
||||
} else {
|
||||
widget.controller.setActiveSpace(spaceChild.roomId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
context.go('/rooms/${spaceChild.roomId}');
|
||||
}
|
||||
final space = client.getRoomById(widget.spaceId);
|
||||
|
||||
void _onSpaceChildContextMenu([
|
||||
SpaceRoomsChunk? spaceChild,
|
||||
Room? room,
|
||||
]) async {
|
||||
final client = Matrix.of(context).client;
|
||||
final activeSpaceId = widget.controller.activeSpaceId;
|
||||
final activeSpace =
|
||||
activeSpaceId == null ? null : client.getRoomById(activeSpaceId);
|
||||
final action = await showModalActionSheet<SpaceChildContextAction>(
|
||||
final joined = await showAdaptiveBottomSheet<bool>(
|
||||
context: context,
|
||||
title: spaceChild?.name ??
|
||||
room?.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
),
|
||||
message: spaceChild?.topic ?? room?.topic,
|
||||
actions: [
|
||||
if (room == null)
|
||||
SheetAction(
|
||||
key: SpaceChildContextAction.join,
|
||||
label: L10n.of(context)!.joinRoom,
|
||||
icon: Icons.send_outlined,
|
||||
),
|
||||
if (spaceChild != null &&
|
||||
(activeSpace?.canChangeStateEvent(EventTypes.SpaceChild) ?? false))
|
||||
SheetAction(
|
||||
key: SpaceChildContextAction.removeFromSpace,
|
||||
label: L10n.of(context)!.removeFromSpace,
|
||||
icon: Icons.delete_sweep_outlined,
|
||||
),
|
||||
if (room != null)
|
||||
SheetAction(
|
||||
key: SpaceChildContextAction.leave,
|
||||
label: L10n.of(context)!.leave,
|
||||
icon: Icons.delete_outlined,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
],
|
||||
builder: (_) => PublicRoomBottomSheet(
|
||||
outerContext: context,
|
||||
chunk: item,
|
||||
via: space?.spaceChildren
|
||||
.firstWhereOrNull(
|
||||
(child) => child.roomId == item.roomId,
|
||||
)
|
||||
?.via,
|
||||
),
|
||||
);
|
||||
if (action == null) return;
|
||||
if (mounted && joined == true) {
|
||||
setState(() {
|
||||
_discoveredChildren.remove(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSpaceAction(SpaceActions action) async {
|
||||
final space = Matrix.of(context).client.getRoomById(widget.spaceId);
|
||||
|
||||
switch (action) {
|
||||
case SpaceChildContextAction.join:
|
||||
_onJoinSpaceChild(spaceChild!);
|
||||
case SpaceActions.settings:
|
||||
await space?.postLoad();
|
||||
context.push('/rooms/${widget.spaceId}/details');
|
||||
break;
|
||||
case SpaceChildContextAction.leave:
|
||||
await showFutureLoadingDialog(
|
||||
case SpaceActions.invite:
|
||||
await space?.postLoad();
|
||||
context.push('/rooms/${widget.spaceId}/invite');
|
||||
break;
|
||||
case SpaceActions.leave:
|
||||
final confirmed = await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
future: room!.leave,
|
||||
title: L10n.of(context)!.areYouSure,
|
||||
okLabel: L10n.of(context)!.ok,
|
||||
cancelLabel: L10n.of(context)!.cancel,
|
||||
message: L10n.of(context)!.archiveRoomDescription,
|
||||
);
|
||||
break;
|
||||
case SpaceChildContextAction.removeFromSpace:
|
||||
await showFutureLoadingDialog(
|
||||
if (!mounted) return;
|
||||
if (confirmed != OkCancelResult.ok) return;
|
||||
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId),
|
||||
future: () async => await space?.leave(),
|
||||
);
|
||||
break;
|
||||
if (!mounted) return;
|
||||
if (success.error != null) return;
|
||||
widget.onBack();
|
||||
}
|
||||
}
|
||||
|
||||
void _addChatOrSubSpace() async {
|
||||
void _addChatOrSubspace() async {
|
||||
final roomType = await showConfirmationDialog(
|
||||
context: context,
|
||||
title: L10n.of(context)!.addChatOrSubSpace,
|
||||
|
|
@ -235,9 +203,8 @@ class _SpaceViewState extends State<SpaceView> {
|
|||
context: context,
|
||||
future: () async {
|
||||
late final String roomId;
|
||||
final activeSpace = client.getRoomById(
|
||||
widget.controller.activeSpaceId!,
|
||||
)!;
|
||||
final activeSpace = client.getRoomById(widget.spaceId)!;
|
||||
await activeSpace.postLoad();
|
||||
|
||||
if (roomType == AddRoomType.subspace) {
|
||||
roomId = await client.createSpace(
|
||||
|
|
@ -250,10 +217,16 @@ class _SpaceViewState extends State<SpaceView> {
|
|||
} else {
|
||||
roomId = await client.createGroupChat(
|
||||
groupName: names.first,
|
||||
preset: activeSpace.joinRules == JoinRules.public
|
||||
? CreateRoomPreset.publicChat
|
||||
: CreateRoomPreset.privateChat,
|
||||
visibility: activeSpace.joinRules == JoinRules.public
|
||||
? sdk.Visibility.public
|
||||
: sdk.Visibility.private,
|
||||
initialState: names.length > 1 && names.last.isNotEmpty
|
||||
? [
|
||||
sdk.StateEvent(
|
||||
type: sdk.EventTypes.RoomTopic,
|
||||
StateEvent(
|
||||
type: EventTypes.RoomTopic,
|
||||
content: {'topic': names.last},
|
||||
),
|
||||
]
|
||||
|
|
@ -264,311 +237,356 @@ class _SpaceViewState extends State<SpaceView> {
|
|||
},
|
||||
);
|
||||
if (result.error != null) return;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = Matrix.of(context).client;
|
||||
final activeSpaceId = widget.controller.activeSpaceId;
|
||||
final activeSpace = activeSpaceId == null
|
||||
? null
|
||||
: client.getRoomById(
|
||||
activeSpaceId,
|
||||
);
|
||||
final allSpaces = client.rooms.where((room) => room.isSpace);
|
||||
if (activeSpaceId == null) {
|
||||
final rootSpaces = allSpaces
|
||||
.where(
|
||||
(space) =>
|
||||
!allSpaces.any(
|
||||
(parentSpace) => parentSpace.spaceChildren
|
||||
.any((child) => child.roomId == space.id),
|
||||
) &&
|
||||
space
|
||||
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!))
|
||||
.toLowerCase()
|
||||
.contains(
|
||||
widget.controller.searchController.text.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return SafeArea(
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
ChatListHeader(controller: widget.controller),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final rootSpace = rootSpaces[i];
|
||||
final displayname = rootSpace.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
);
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: rootSpace.avatar,
|
||||
name: displayname,
|
||||
),
|
||||
title: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
L10n.of(context)!.numChats(
|
||||
rootSpace.spaceChildren.length.toString(),
|
||||
),
|
||||
),
|
||||
onTap: () =>
|
||||
widget.controller.setActiveSpace(rootSpace.id),
|
||||
onLongPress: () =>
|
||||
_onSpaceChildContextMenu(null, rootSpace),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: rootSpaces.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
final room = Matrix.of(context).client.getRoomById(widget.spaceId);
|
||||
final displayname =
|
||||
room?.getLocalizedDisplayname() ?? L10n.of(context)!.nothingFound;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Center(
|
||||
child: CloseButton(
|
||||
onPressed: widget.onBack,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final parentSpace = allSpaces.firstWhereOrNull(
|
||||
(space) =>
|
||||
space.spaceChildren.any((child) => child.roomId == activeSpaceId),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: parentSpace == null,
|
||||
onPopInvoked: (pop) async {
|
||||
if (pop) return;
|
||||
if (parentSpace != null) {
|
||||
widget.controller.setActiveSpace(parentSpace.id);
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
ChatListHeader(controller: widget.controller, globalSearch: false),
|
||||
SliverAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
primary: false,
|
||||
titleSpacing: 0,
|
||||
title: ListTile(
|
||||
leading: BackButton(
|
||||
onPressed: () =>
|
||||
widget.controller.setActiveSpace(parentSpace?.id),
|
||||
titleSpacing: 0,
|
||||
title: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Avatar(
|
||||
mxContent: room?.avatar,
|
||||
name: displayname,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
title: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: room == null
|
||||
? null
|
||||
: Text(
|
||||
L10n.of(context)!.countChatsAndCountParticipants(
|
||||
room.spaceChildren.length,
|
||||
room.summary.mJoinedMemberCount ?? 1,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
title: Text(
|
||||
parentSpace == null
|
||||
? L10n.of(context)!.allSpaces
|
||||
: parentSpace.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)!),
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: loading
|
||||
? const CircularProgressIndicator.adaptive(strokeWidth: 2)
|
||||
: const Icon(Icons.refresh_outlined),
|
||||
onPressed: loading ? null : _refresh,
|
||||
),
|
||||
actions: [
|
||||
PopupMenuButton<SpaceActions>(
|
||||
onSelected: _onSpaceAction,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.settings,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.settings_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context)!.settings),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final response = _lastResponse[activeSpaceId];
|
||||
final error = this.error;
|
||||
if (error != null) {
|
||||
return SliverFillRemaining(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(error.toLocalizedString(context)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _refresh,
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (response == null) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text(L10n.of(context)!.loadingPleaseWait),
|
||||
),
|
||||
);
|
||||
}
|
||||
final spaceChildren = response.rooms;
|
||||
final canLoadMore = response.nextBatch != null;
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
if (canLoadMore && i == spaceChildren.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: OutlinedButton.icon(
|
||||
label: loading
|
||||
? const LinearProgressIndicator()
|
||||
: Text(L10n.of(context)!.loadMore),
|
||||
icon: const Icon(Icons.chevron_right_outlined),
|
||||
onPressed: loading
|
||||
? null
|
||||
: () {
|
||||
loadHierarchy(response.nextBatch);
|
||||
},
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.invite,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.person_add_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context)!.invite),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SpaceActions.leave,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.delete_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context)!.leave),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: room == null
|
||||
? const Center(
|
||||
child: Icon(
|
||||
Icons.search_outlined,
|
||||
size: 80,
|
||||
),
|
||||
)
|
||||
: StreamBuilder(
|
||||
stream: room.client.onSync.stream
|
||||
.where((s) => s.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) {
|
||||
final childrenIds = room.spaceChildren
|
||||
.map((c) => c.roomId)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
|
||||
final joinedRooms = room.client.rooms
|
||||
.where((room) => childrenIds.remove(room.id))
|
||||
.toList();
|
||||
|
||||
final joinedParents = room.spaceParents
|
||||
.map((parent) {
|
||||
final roomId = parent.roomId;
|
||||
if (roomId == null) return null;
|
||||
return room.client.getRoomById(roomId);
|
||||
})
|
||||
.whereType<Room>()
|
||||
.toList();
|
||||
final filter = _filterController.text.trim().toLowerCase();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
toolbarHeight: 72,
|
||||
scrolledUnderElevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
title: TextField(
|
||||
controller: _filterController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
fillColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
);
|
||||
}
|
||||
final spaceChild = spaceChildren[i];
|
||||
final room = client.getRoomById(spaceChild.roomId);
|
||||
if (room != null && !room.isSpace) {
|
||||
return ChatListItem(
|
||||
room,
|
||||
onLongPress: () =>
|
||||
_onSpaceChildContextMenu(spaceChild, room),
|
||||
activeChat: widget.controller.activeChat == room.id,
|
||||
onTap: () => onChatTap(room, context),
|
||||
);
|
||||
}
|
||||
final isSpace = spaceChild.roomType == 'm.space';
|
||||
final topic = spaceChild.topic?.isEmpty ?? true
|
||||
? null
|
||||
: spaceChild.topic;
|
||||
if (spaceChild.roomId == activeSpaceId) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SearchTitle(
|
||||
title: spaceChild.name ??
|
||||
spaceChild.canonicalAlias ??
|
||||
'Space',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
),
|
||||
child: Avatar(
|
||||
size: 24,
|
||||
mxContent: spaceChild.avatarUrl,
|
||||
name: spaceChild.name,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: L10n.of(context)!.search,
|
||||
hintStyle: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
prefixIcon: IconButton(
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Icons.search_outlined,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.withAlpha(128),
|
||||
trailing: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Icon(Icons.edit_outlined),
|
||||
),
|
||||
onTap: () => _onJoinSpaceChild(spaceChild),
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
if (activeSpace?.canChangeStateEvent(
|
||||
EventTypes.SpaceChild,
|
||||
) ==
|
||||
true)
|
||||
Material(
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.group_add_outlined),
|
||||
),
|
||||
title:
|
||||
Text(L10n.of(context)!.addChatOrSubSpace),
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right_outlined),
|
||||
onTap: _addChatOrSubSpace,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final name = spaceChild.name ??
|
||||
spaceChild.canonicalAlias ??
|
||||
L10n.of(context)!.chat;
|
||||
if (widget.controller.isSearchMode &&
|
||||
!name.toLowerCase().contains(
|
||||
widget.controller.searchController.text
|
||||
.toLowerCase(),
|
||||
)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Material(
|
||||
child: ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: spaceChild.avatarUrl,
|
||||
name: spaceChild.name,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: joinedParents.length,
|
||||
itemBuilder: (context, i) {
|
||||
final displayname =
|
||||
joinedParents[i].getLocalizedDisplayname();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 1,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ListTile(
|
||||
minVerticalPadding: 0,
|
||||
leading: Icon(
|
||||
Icons.adaptive.arrow_back_outlined,
|
||||
size: 16,
|
||||
),
|
||||
if (!isSpace) ...[
|
||||
const Icon(
|
||||
Icons.people_outline,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
spaceChild.numJoinedMembers.toString(),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
title: Row(
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: joinedParents[i].avatar,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize / 2,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 4,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(displayname)),
|
||||
],
|
||||
),
|
||||
onTap: () =>
|
||||
widget.toParentSpace(joinedParents[i].id),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: joinedRooms.length + 1,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (room.canChangeStateEvent(
|
||||
EventTypes.SpaceChild,
|
||||
) &&
|
||||
filter.isEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 1,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ListTile(
|
||||
onTap: _addChatOrSubspace,
|
||||
leading: const CircleAvatar(
|
||||
radius: Avatar.defaultSize / 2,
|
||||
child: Icon(Icons.add_outlined),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context)!.addChatOrSubSpace,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SearchTitle(
|
||||
title: L10n.of(context)!.joinedChats,
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
i--;
|
||||
final joinedRoom = joinedRooms[i];
|
||||
return ChatListItem(
|
||||
joinedRoom,
|
||||
filter: filter,
|
||||
onTap: () => widget.onChatTab(joinedRoom),
|
||||
onLongPress: (context) => widget.onChatContext(
|
||||
joinedRoom,
|
||||
context,
|
||||
),
|
||||
onTap: () => room?.isSpace == true
|
||||
? widget.controller.setActiveSpace(room!.id)
|
||||
: _onSpaceChildContextMenu(spaceChild, room),
|
||||
onLongPress: () =>
|
||||
_onSpaceChildContextMenu(spaceChild, room),
|
||||
subtitle: Text(
|
||||
topic ??
|
||||
(isSpace
|
||||
? L10n.of(context)!.enterSpace
|
||||
: L10n.of(context)!.enterRoom),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
activeChat: widget.activeChat == joinedRoom.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: _discoveredChildren.length + 2,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
return SearchTitle(
|
||||
title: L10n.of(context)!.discover,
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
);
|
||||
}
|
||||
i--;
|
||||
if (i == _discoveredChildren.length) {
|
||||
if (_noMoreRooms) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
L10n.of(context)!.noMoreChatsFound,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
child: TextButton(
|
||||
onPressed: _isLoading ? null : _loadHierarchy,
|
||||
child: _isLoading
|
||||
? LinearProgressIndicator(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
)
|
||||
: Text(L10n.of(context)!.loadMore),
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = _discoveredChildren[i];
|
||||
final displayname = item.name ??
|
||||
item.canonicalAlias ??
|
||||
L10n.of(context)!.emptyChat;
|
||||
if (!displayname.toLowerCase().contains(filter)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 1,
|
||||
),
|
||||
child: Material(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ListTile(
|
||||
onTap: () => _joinChildRoom(item),
|
||||
leading: Avatar(
|
||||
mxContent: item.avatarUrl,
|
||||
name: displayname,
|
||||
borderRadius: item.roomType == 'm.space'
|
||||
? BorderRadius.circular(
|
||||
AppConfig.borderRadius / 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.add_circle_outline_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
item.topic ??
|
||||
L10n.of(context)!.countParticipants(
|
||||
item.numJoinedMembers,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: isSpace
|
||||
? const Icon(Icons.chevron_right_outlined)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: spaceChildren.length + (canLoadMore ? 1 : 0),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum SpaceChildContextAction {
|
||||
join,
|
||||
enum SpaceActions {
|
||||
settings,
|
||||
invite,
|
||||
leave,
|
||||
removeFromSpace,
|
||||
}
|
||||
|
||||
enum AddRoomType { chat, subspace }
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import 'chat_list.dart';
|
||||
|
||||
class StartChatFloatingActionButton extends StatelessWidget {
|
||||
final ActiveFilter activeFilter;
|
||||
final ValueNotifier<bool> scrolledToTop;
|
||||
final bool roomsIsEmpty;
|
||||
final void Function() createNewSpace;
|
||||
|
||||
const StartChatFloatingActionButton({
|
||||
super.key,
|
||||
required this.activeFilter,
|
||||
required this.scrolledToTop,
|
||||
required this.roomsIsEmpty,
|
||||
required this.createNewSpace,
|
||||
});
|
||||
|
||||
void _onPressed(BuildContext context) async {
|
||||
switch (activeFilter) {
|
||||
case ActiveFilter.allChats:
|
||||
case ActiveFilter.messages:
|
||||
context.go('/rooms/newprivatechat');
|
||||
break;
|
||||
case ActiveFilter.groups:
|
||||
context.go('/rooms/newgroup');
|
||||
break;
|
||||
case ActiveFilter.spaces:
|
||||
createNewSpace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (activeFilter) {
|
||||
case ActiveFilter.allChats:
|
||||
case ActiveFilter.messages:
|
||||
return Icons.add_outlined;
|
||||
case ActiveFilter.groups:
|
||||
return Icons.group_add_outlined;
|
||||
case ActiveFilter.spaces:
|
||||
return Icons.workspaces_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
String getLabel(BuildContext context) {
|
||||
switch (activeFilter) {
|
||||
case ActiveFilter.allChats:
|
||||
case ActiveFilter.messages:
|
||||
return roomsIsEmpty
|
||||
? L10n.of(context)!.startFirstChat
|
||||
: L10n.of(context)!.newChat;
|
||||
case ActiveFilter.groups:
|
||||
return L10n.of(context)!.newGroup;
|
||||
case ActiveFilter.spaces:
|
||||
return L10n.of(context)!.newSpace;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: scrolledToTop,
|
||||
builder: (context, scrolledToTop, _) => AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.none,
|
||||
child: scrolledToTop
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _onPressed(context),
|
||||
icon: Icon(icon),
|
||||
label: Text(
|
||||
getLabel(context),
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
)
|
||||
: FloatingActionButton(
|
||||
onPressed: () => _onPressed(context),
|
||||
child: Icon(icon),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
void onChatTap(Room room, BuildContext context) async {
|
||||
if (room.membership == Membership.invite) {
|
||||
final inviterId =
|
||||
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
|
||||
final inviteAction = await showModalActionSheet<InviteActions>(
|
||||
context: context,
|
||||
message: room.isDirectChat
|
||||
? L10n.of(context)!.invitePrivateChat
|
||||
: L10n.of(context)!.inviteGroupChat,
|
||||
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
|
||||
actions: [
|
||||
SheetAction(
|
||||
key: InviteActions.accept,
|
||||
label: L10n.of(context)!.accept,
|
||||
icon: Icons.check_outlined,
|
||||
isDefaultAction: true,
|
||||
),
|
||||
SheetAction(
|
||||
key: InviteActions.decline,
|
||||
label: L10n.of(context)!.decline,
|
||||
icon: Icons.close_outlined,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
SheetAction(
|
||||
key: InviteActions.block,
|
||||
label: L10n.of(context)!.block,
|
||||
icon: Icons.block_outlined,
|
||||
isDestructiveAction: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (inviteAction == null) return;
|
||||
if (inviteAction == InviteActions.block) {
|
||||
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
|
||||
return;
|
||||
}
|
||||
if (inviteAction == InviteActions.decline) {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: room.leave,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final joinResult = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final waitForRoom = room.client.waitForRoomInSync(
|
||||
room.id,
|
||||
join: true,
|
||||
);
|
||||
await room.join();
|
||||
await waitForRoom;
|
||||
},
|
||||
);
|
||||
if (joinResult.error != null) return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.ban) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.membership == Membership.leave) {
|
||||
context.go('/rooms/archive/${room.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Share content into this room
|
||||
final shareContent = Matrix.of(context).shareContent;
|
||||
if (shareContent != null) {
|
||||
final shareFile = shareContent.tryGet<MatrixFile>('file');
|
||||
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
|
||||
shareFile != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (c) => SendFileDialog(
|
||||
files: [shareFile],
|
||||
room: room,
|
||||
),
|
||||
);
|
||||
Matrix.of(context).shareContent = null;
|
||||
} else {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context)!.forward,
|
||||
message: L10n.of(context)!.forwardMessageTo(
|
||||
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
|
||||
),
|
||||
okLabel: L10n.of(context)!.forward,
|
||||
cancelLabel: L10n.of(context)!.cancel,
|
||||
);
|
||||
if (consent == OkCancelResult.cancel) {
|
||||
Matrix.of(context).shareContent = null;
|
||||
return;
|
||||
}
|
||||
if (consent == OkCancelResult.ok) {
|
||||
room.sendEvent(shareContent);
|
||||
Matrix.of(context).shareContent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.go('/rooms/${room.id}');
|
||||
}
|
||||
|
||||
enum InviteActions {
|
||||
accept,
|
||||
decline,
|
||||
block,
|
||||
}
|
||||
|
|
@ -41,6 +41,22 @@ class ChatPermissionsSettingsView extends StatelessWidget {
|
|||
)..removeWhere((k, v) => v is! int);
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outlined),
|
||||
subtitle: Text(
|
||||
L10n.of(context)!.chatPermissionsDescription,
|
||||
),
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
title: Text(
|
||||
L10n.of(context)!.chatPermissions,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class PermissionsListTile extends StatelessWidget {
|
|||
case 'events_default':
|
||||
return L10n.of(context)!.sendMessages;
|
||||
case 'state_default':
|
||||
return L10n.of(context)!.configureChat;
|
||||
return L10n.of(context)!.changeGeneralChatSettings;
|
||||
case 'ban':
|
||||
return L10n.of(context)!.banFromChat;
|
||||
case 'kick':
|
||||
|
|
@ -37,23 +37,25 @@ class PermissionsListTile extends StatelessWidget {
|
|||
case 'redact':
|
||||
return L10n.of(context)!.deleteMessage;
|
||||
case 'invite':
|
||||
return L10n.of(context)!.inviteContact;
|
||||
return L10n.of(context)!.inviteOtherUsers;
|
||||
}
|
||||
} else if (category == 'notifications') {
|
||||
switch (permissionKey) {
|
||||
case 'rooms':
|
||||
return L10n.of(context)!.notifications;
|
||||
return L10n.of(context)!.sendRoomNotifications;
|
||||
}
|
||||
} else if (category == 'events') {
|
||||
switch (permissionKey) {
|
||||
case EventTypes.RoomName:
|
||||
return L10n.of(context)!.changeTheNameOfTheGroup;
|
||||
case EventTypes.RoomTopic:
|
||||
return L10n.of(context)!.changeTheDescriptionOfTheGroup;
|
||||
case EventTypes.RoomPowerLevels:
|
||||
return L10n.of(context)!.chatPermissions;
|
||||
return L10n.of(context)!.changeTheChatPermissions;
|
||||
case EventTypes.HistoryVisibility:
|
||||
return L10n.of(context)!.visibilityOfTheChatHistory;
|
||||
return L10n.of(context)!.changeTheVisibilityOfChatHistory;
|
||||
case EventTypes.RoomCanonicalAlias:
|
||||
return L10n.of(context)!.setInvitationLink;
|
||||
return L10n.of(context)!.changeTheCanonicalRoomAlias;
|
||||
case EventTypes.RoomAvatar:
|
||||
return L10n.of(context)!.editRoomAvatar;
|
||||
case EventTypes.RoomTombstone:
|
||||
|
|
@ -69,32 +71,46 @@ class PermissionsListTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = permission >= 100
|
||||
? Colors.orangeAccent
|
||||
: permission >= 50
|
||||
? Colors.blueAccent
|
||||
: Colors.greenAccent;
|
||||
return ListTile(
|
||||
title: Text(getLocalizedPowerLevelString(context)),
|
||||
subtitle: Text(
|
||||
L10n.of(context)!.minimumPowerLevel(permission.toString()),
|
||||
title: Text(
|
||||
getLocalizedPowerLevelString(context),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
trailing: Material(
|
||||
color: color.withAlpha(32),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
color: Theme.of(context).colorScheme.onInverseSurface,
|
||||
child: DropdownButton<int>(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
underline: const SizedBox.shrink(),
|
||||
onChanged: canEdit ? onChanged : null,
|
||||
value: {0, 50, 100}.contains(permission) ? permission : null,
|
||||
value: permission,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text(L10n.of(context)!.user),
|
||||
value: permission < 50 ? permission : 0,
|
||||
child: Text(
|
||||
L10n.of(context)!.userLevel(permission < 50 ? permission : 0),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 50,
|
||||
child: Text(L10n.of(context)!.moderator),
|
||||
value: permission < 100 && permission >= 50 ? permission : 50,
|
||||
child: Text(
|
||||
L10n.of(context)!.moderatorLevel(
|
||||
permission < 100 && permission >= 50 ? permission : 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 100,
|
||||
child: Text(L10n.of(context)!.admin),
|
||||
value: permission >= 100 ? permission : 100,
|
||||
child: Text(
|
||||
L10n.of(context)!
|
||||
.adminLevel(permission >= 100 ? permission : 100),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: null,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ class NewGroup extends StatefulWidget {
|
|||
class NewGroupController extends State<NewGroup> {
|
||||
TextEditingController nameController = TextEditingController();
|
||||
|
||||
TextEditingController topicController = TextEditingController();
|
||||
|
||||
bool publicGroup = false;
|
||||
bool groupCanBeFound = true;
|
||||
|
||||
|
|
@ -71,11 +69,6 @@ class NewGroupController extends State<NewGroup> {
|
|||
: sdk.CreateRoomPreset.privateChat,
|
||||
groupName: nameController.text.isNotEmpty ? nameController.text : null,
|
||||
initialState: [
|
||||
if (topicController.text.isNotEmpty)
|
||||
sdk.StateEvent(
|
||||
type: sdk.EventTypes.RoomTopic,
|
||||
content: {'topic': topicController.text},
|
||||
),
|
||||
if (avatar != null)
|
||||
sdk.StateEvent(
|
||||
type: sdk.EventTypes.RoomAvatar,
|
||||
|
|
|
|||
|
|
@ -31,54 +31,35 @@ class NewGroupView extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
onTap: controller.loading ? null : controller.selectPhoto,
|
||||
child: CircleAvatar(
|
||||
radius: Avatar.defaultSize / 2,
|
||||
child: avatar == null
|
||||
? const Icon(Icons.camera_alt_outlined)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
child: Image.memory(
|
||||
avatar,
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller.nameController,
|
||||
autocorrect: false,
|
||||
readOnly: controller.loading,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.people_outlined),
|
||||
hintText: L10n.of(context)!.groupName,
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
onTap: controller.loading ? null : controller.selectPhoto,
|
||||
child: CircleAvatar(
|
||||
radius: Avatar.defaultSize,
|
||||
child: avatar == null
|
||||
? const Icon(Icons.add_a_photo_outlined)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
child: Image.memory(
|
||||
avatar,
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
controller: controller.topicController,
|
||||
minLines: 4,
|
||||
maxLines: 4,
|
||||
maxLength: 255,
|
||||
autofocus: true,
|
||||
controller: controller.nameController,
|
||||
autocorrect: false,
|
||||
readOnly: controller.loading,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context)!.addChatDescription,
|
||||
prefixIcon: const Icon(Icons.people_outlined),
|
||||
hintText: L10n.of(context)!.groupName,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -121,10 +102,6 @@ class NewGroupView extends StatelessWidget {
|
|||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed:
|
||||
controller.loading ? null : controller.submitAction,
|
||||
child: controller.loading
|
||||
|
|
|
|||
|
|
@ -22,65 +22,37 @@ class NewSpaceView extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
trailing: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Icon(Icons.info_outlined),
|
||||
),
|
||||
subtitle: Text(L10n.of(context)!.newSpaceDescription),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
onTap: controller.loading ? null : controller.selectPhoto,
|
||||
child: CircleAvatar(
|
||||
radius: Avatar.defaultSize / 2,
|
||||
child: avatar == null
|
||||
? const Icon(Icons.camera_alt_outlined)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
child: Image.memory(
|
||||
avatar,
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller.nameController,
|
||||
autocorrect: false,
|
||||
readOnly: controller.loading,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.people_outlined),
|
||||
hintText: L10n.of(context)!.spaceName,
|
||||
errorText: controller.nameError,
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
onTap: controller.loading ? null : controller.selectPhoto,
|
||||
child: CircleAvatar(
|
||||
radius: Avatar.defaultSize,
|
||||
child: avatar == null
|
||||
? const Icon(Icons.add_a_photo_outlined)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
child: Image.memory(
|
||||
avatar,
|
||||
width: Avatar.defaultSize,
|
||||
height: Avatar.defaultSize,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextField(
|
||||
controller: controller.topicController,
|
||||
minLines: 4,
|
||||
maxLines: 4,
|
||||
maxLength: 255,
|
||||
autofocus: true,
|
||||
controller: controller.nameController,
|
||||
autocorrect: false,
|
||||
readOnly: controller.loading,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context)!.addChatDescription,
|
||||
errorText: controller.topicError,
|
||||
prefixIcon: const Icon(Icons.people_outlined),
|
||||
hintText: L10n.of(context)!.spaceName,
|
||||
errorText: controller.nameError,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -90,15 +62,18 @@ class NewSpaceView extends StatelessWidget {
|
|||
value: controller.publicGroup,
|
||||
onChanged: controller.setPublicGroup,
|
||||
),
|
||||
ListTile(
|
||||
trailing: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Icon(Icons.info_outlined),
|
||||
),
|
||||
subtitle: Text(L10n.of(context)!.newSpaceDescription),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onPressed:
|
||||
controller.loading ? null : controller.submitAction,
|
||||
child: controller.loading
|
||||
|
|
|
|||
|
|
@ -209,8 +209,6 @@ class SettingsController extends State<Settings> {
|
|||
final client = Matrix.of(context).client;
|
||||
profileFuture ??= client.getProfileFromUserId(
|
||||
client.userID!,
|
||||
cache: !profileUpdated,
|
||||
getFromRooms: !profileUpdated,
|
||||
);
|
||||
return SettingsView(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,6 @@ class SettingsView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
title: Text(L10n.of(context)!.settings),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: controller.logoutAction,
|
||||
label: Text(L10n.of(context)!.logout),
|
||||
icon: const Icon(Icons.logout_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListTileTheme(
|
||||
iconColor: Theme.of(context).colorScheme.onSurface,
|
||||
|
|
@ -55,32 +48,17 @@ class SettingsView extends StatelessWidget {
|
|||
padding: const EdgeInsets.all(32.0),
|
||||
child: Stack(
|
||||
children: [
|
||||
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: profile?.avatarUrl,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize * 2.5,
|
||||
),
|
||||
Avatar(
|
||||
mxContent: profile?.avatarUrl,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize * 2.5,
|
||||
),
|
||||
if (profile != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: FloatingActionButton.small(
|
||||
elevation: 2,
|
||||
onPressed: controller.setAvatarAction,
|
||||
heroTag: null,
|
||||
child: const Icon(Icons.camera_alt_outlined),
|
||||
|
|
@ -108,7 +86,9 @@ class SettingsView extends StatelessWidget {
|
|||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// style: const TextStyle(fontSize: 18),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
|
|
@ -135,10 +115,7 @@ class SettingsView extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
if (showChatBackupBanner == null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.backup_outlined),
|
||||
|
|
@ -154,60 +131,54 @@ class SettingsView extends StatelessWidget {
|
|||
onChanged: controller.firstRunBootstrapAction,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.format_paint_outlined),
|
||||
title: Text(L10n.of(context)!.changeTheme),
|
||||
onTap: () => context.go('/rooms/settings/style'),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: Text(L10n.of(context)!.notifications),
|
||||
onTap: () => context.go('/rooms/settings/notifications'),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.devices_outlined),
|
||||
title: Text(L10n.of(context)!.devices),
|
||||
onTap: () => context.go('/rooms/settings/devices'),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.forum_outlined),
|
||||
title: Text(L10n.of(context)!.chat),
|
||||
onTap: () => context.go('/rooms/settings/chat'),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shield_outlined),
|
||||
title: Text(L10n.of(context)!.security),
|
||||
onTap: () => context.go('/rooms/settings/security'),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.help_outline_outlined),
|
||||
title: Text(L10n.of(context)!.help),
|
||||
onTap: () => launchUrlString(AppConfig.supportUrl),
|
||||
trailing: const Icon(Icons.open_in_new_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shield_sharp),
|
||||
title: Text(L10n.of(context)!.privacy),
|
||||
onTap: () => launchUrlString(AppConfig.privacyUrl),
|
||||
trailing: const Icon(Icons.open_in_new_outlined),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline_rounded),
|
||||
title: Text(L10n.of(context)!.about),
|
||||
onTap: () => PlatformInfos.showDialog(context),
|
||||
trailing: const Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout_outlined),
|
||||
title: Text(L10n.of(context)!.logout),
|
||||
onTap: controller.logoutAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class Settings3PidView extends StatelessWidget {
|
|||
.withTheseAddressesRecoveryDescription,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: identifier.length,
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@ class SettingsChatView extends StatelessWidget {
|
|||
storeKey: SettingKeys.swipeRightToLeftToReply,
|
||||
defaultValue: AppConfig.swipeRightToLeftToReply,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
title: Text(
|
||||
L10n.of(context)!.customEmojisAndStickers,
|
||||
|
|
@ -93,10 +90,7 @@ class SettingsChatView extends StatelessWidget {
|
|||
child: Icon(Icons.chevron_right_outlined),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
ListTile(
|
||||
title: Text(
|
||||
L10n.of(context)!.calls,
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class EmotesSettingsView extends StatelessWidget {
|
|||
onChanged: controller.setIsGloballyActive,
|
||||
),
|
||||
if (!controller.readonly || controller.room != null)
|
||||
const Divider(thickness: 1),
|
||||
const Divider(),
|
||||
imageKeys.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ class SettingsIgnoreListController extends State<SettingsIgnoreList> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialUserId != null) {
|
||||
controller.text = widget.initialUserId!.replaceAll('@', '');
|
||||
final initialUserId = widget.initialUserId;
|
||||
if (initialUserId != null) {
|
||||
controller.text = initialUserId;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ class SettingsSecurityView extends StatelessWidget {
|
|||
),
|
||||
},
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@ class SettingsStyleView extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
ListTile(
|
||||
|
|
@ -167,7 +166,6 @@ class SettingsStyleView extends StatelessWidget {
|
|||
onChanged: controller.switchTheme,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
ListTile(
|
||||
|
|
@ -192,7 +190,6 @@ class SettingsStyleView extends StatelessWidget {
|
|||
defaultValue: AppConfig.separateChatTypes,
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -226,6 +226,45 @@ class UserBottomSheetController extends State<UserBottomSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
bool isSending = false;
|
||||
|
||||
Object? sendError;
|
||||
|
||||
final TextEditingController sendController = TextEditingController();
|
||||
|
||||
void sendAction([_]) async {
|
||||
final userId = widget.user?.id ?? widget.profile?.userId;
|
||||
final client = Matrix.of(widget.outerContext).client;
|
||||
if (userId == null) throw ('user or profile must not be null!');
|
||||
|
||||
final input = sendController.text.trim();
|
||||
if (input.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
isSending = true;
|
||||
sendError = null;
|
||||
});
|
||||
try {
|
||||
final roomId = await client.startDirectChat(userId);
|
||||
if (!mounted) return;
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
throw ('DM Room found or created but room not found in client');
|
||||
}
|
||||
await room.sendTextEvent(input);
|
||||
setState(() {
|
||||
isSending = false;
|
||||
sendController.clear();
|
||||
});
|
||||
} catch (e, s) {
|
||||
Logs().d('Unable to send message', e, s);
|
||||
setState(() {
|
||||
isSending = false;
|
||||
sendError = e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void knockAccept() async {
|
||||
final user = widget.user!;
|
||||
final result = await showFutureLoadingDialog(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
|
|||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/utils/date_time_extension.dart';
|
||||
import 'package:fluffychat/utils/fluffy_share.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/url_launcher.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/presence_builder.dart';
|
||||
|
|
@ -29,6 +30,7 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
|
||||
final client = Matrix.of(controller.widget.outerContext).client;
|
||||
final profileSearchError = controller.widget.profileSearchError;
|
||||
final dmRoomId = client.getDirectChatFromUserId(userId);
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -36,73 +38,20 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
onPressed: Navigator.of(context, rootNavigator: false).pop,
|
||||
),
|
||||
centerTitle: false,
|
||||
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.orange
|
||||
: Colors.grey;
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
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.only(right: 8.0),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.block_outlined),
|
||||
tooltip: L10n.of(context)!.block,
|
||||
onPressed: () => controller
|
||||
.participantAction(UserBottomSheetAction.ignore),
|
||||
),
|
||||
),
|
||||
],
|
||||
title: Text(displayname),
|
||||
actions: dmRoomId == null
|
||||
? null
|
||||
: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FloatingActionButton.small(
|
||||
elevation: 0,
|
||||
onPressed: () => controller
|
||||
.participantAction(UserBottomSheetAction.message),
|
||||
child: const Icon(Icons.chat_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<Object>(
|
||||
stream: user?.room.client.onSync.stream.where(
|
||||
|
|
@ -169,25 +118,12 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
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,
|
||||
),
|
||||
child: Avatar(
|
||||
client:
|
||||
Matrix.of(controller.widget.outerContext).client,
|
||||
mxContent: avatarUrl,
|
||||
name: displayname,
|
||||
size: Avatar.defaultSize * 2.5,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
|
@ -195,26 +131,6 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
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.onSurface,
|
||||
),
|
||||
label: Text(
|
||||
displayname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => FluffyShare.share(
|
||||
userId,
|
||||
|
|
@ -227,37 +143,72 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
label: Text(
|
||||
userId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
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.orange
|
||||
: Colors.grey;
|
||||
|
||||
final lastActiveTimestamp =
|
||||
presence.lastActiveTimestamp;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
controller.widget.user == null
|
||||
? L10n.of(context)!.startConversation
|
||||
: L10n.of(context)!.sendAMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
PresenceBuilder(
|
||||
userId: userId,
|
||||
client: client,
|
||||
|
|
@ -281,6 +232,49 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
);
|
||||
},
|
||||
),
|
||||
if (userId != client.userID)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: dmRoomId == null
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => controller.participantAction(
|
||||
UserBottomSheetAction.message,
|
||||
),
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
label: Text(L10n.of(context)!.startConversation),
|
||||
)
|
||||
: TextField(
|
||||
controller: controller.sendController,
|
||||
readOnly: controller.isSending,
|
||||
onSubmitted: controller.sendAction,
|
||||
minLines: 1,
|
||||
maxLines: 1,
|
||||
textInputAction: TextInputAction.send,
|
||||
decoration: InputDecoration(
|
||||
errorText: controller.sendError
|
||||
?.toLocalizedString(context),
|
||||
hintText: L10n.of(context)!.sendMessages,
|
||||
suffix: controller.isSending
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: controller.isSending
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
onPressed: controller.sendAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.widget.onMention != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.alternate_email_outlined),
|
||||
|
|
@ -334,8 +328,8 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
],
|
||||
Divider(color: Theme.of(context).dividerColor),
|
||||
if (user != null && user.canKick)
|
||||
ListTile(
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
|
|
@ -370,7 +364,7 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
textColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
iconColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
title: Text(L10n.of(context)!.reportUser),
|
||||
leading: const Icon(Icons.report_outlined),
|
||||
leading: const Icon(Icons.gavel_outlined),
|
||||
onTap: () => controller
|
||||
.participantAction(UserBottomSheetAction.report),
|
||||
),
|
||||
|
|
@ -385,6 +379,16 @@ class UserBottomSheetView extends StatelessWidget {
|
|||
style: const TextStyle(color: Colors.orange),
|
||||
),
|
||||
),
|
||||
if (userId != client.userID &&
|
||||
!client.ignoredUsers.contains(userId))
|
||||
ListTile(
|
||||
textColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
iconColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
leading: const Icon(Icons.block_outlined),
|
||||
title: Text(L10n.of(context)!.block),
|
||||
onTap: () => controller
|
||||
.participantAction(UserBottomSheetAction.ignore),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,18 +8,19 @@ Future<T?> showAdaptiveBottomSheet<T>({
|
|||
required Widget Function(BuildContext) builder,
|
||||
bool isDismissible = true,
|
||||
bool isScrollControlled = true,
|
||||
double maxHeight = 480.0,
|
||||
double maxHeight = 512,
|
||||
bool useRootNavigator = true,
|
||||
}) =>
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: builder,
|
||||
// this sadly is ugly on desktops but otherwise breaks `.of(context)` calls
|
||||
useRootNavigator: false,
|
||||
useRootNavigator: useRootNavigator,
|
||||
isDismissible: isDismissible,
|
||||
isScrollControlled: isScrollControlled,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: maxHeight,
|
||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
||||
maxWidth: FluffyThemes.columnWidth * 1.25,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: const RoundedRectangleBorder(
|
||||
|
|
|
|||
|
|
@ -344,4 +344,7 @@ class MatrixLocals extends MatrixLocalizations {
|
|||
@override
|
||||
String startedKeyVerification(String senderName) =>
|
||||
l10n.startedKeyVerification(senderName);
|
||||
|
||||
@override
|
||||
String invitedBy(String senderName) => l10n.invitedBy(senderName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ abstract class PlatformInfos {
|
|||
|
||||
static bool get usesTouchscreen => !isMobile;
|
||||
|
||||
static bool get platformCanRecord => (isMobile || isMacOS);
|
||||
static bool get platformCanRecord => (isMobile || isMacOS || isWeb);
|
||||
|
||||
static String get clientName =>
|
||||
'${AppConfig.applicationName} ${isWeb ? 'web' : Platform.operatingSystem}${kReleaseMode ? '' : 'Debug'}';
|
||||
|
|
|
|||
52
lib/utils/show_update_snackbar.dart
Normal file
52
lib/utils/show_update_snackbar.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
|
||||
abstract class UpdateNotifier {
|
||||
static const String versionStoreKey = 'last_known_version';
|
||||
|
||||
static void showUpdateSnackBar(BuildContext context) async {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final currentVersion = await PlatformInfos.getVersion();
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final storedVersion = store.getString(versionStoreKey);
|
||||
|
||||
if (currentVersion != storedVersion) {
|
||||
if (storedVersion != null) {
|
||||
ScaffoldFeatureController? controller;
|
||||
controller = scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 30),
|
||||
content: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.close_outlined,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
onPressed: () => controller?.close(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
L10n.of(context)!.updateInstalled(currentVersion),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: L10n.of(context)!.changelog,
|
||||
onPressed: () => launchUrlString(AppConfig.changelogUrl),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await store.setString(versionStoreKey, currentVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ class Avatar extends StatelessWidget {
|
|||
final Client? client;
|
||||
final String? presenceUserId;
|
||||
final Color? presenceBackgroundColor;
|
||||
final BorderRadius? borderRadius;
|
||||
final IconData? icon;
|
||||
final BorderSide? border;
|
||||
|
||||
const Avatar({
|
||||
this.mxContent,
|
||||
|
|
@ -24,6 +27,9 @@ class Avatar extends StatelessWidget {
|
|||
this.client,
|
||||
this.presenceUserId,
|
||||
this.presenceBackgroundColor,
|
||||
this.borderRadius,
|
||||
this.border,
|
||||
this.icon,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -41,82 +47,98 @@ class Avatar extends StatelessWidget {
|
|||
final noPic = mxContent == null ||
|
||||
mxContent.toString().isEmpty ||
|
||||
mxContent.toString() == 'null';
|
||||
final textWidget = Center(
|
||||
final textColor = name?.lightColorAvatar;
|
||||
final textWidget = Container(
|
||||
color: textColor,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
fallbackLetters,
|
||||
style: TextStyle(
|
||||
color: noPic ? Colors.white : null,
|
||||
fontSize: (size / 2.5).roundToDouble(),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: (size / 3).roundToDouble(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final borderRadius = BorderRadius.circular(size / 2);
|
||||
final borderRadius = this.borderRadius ?? BorderRadius.circular(size / 2);
|
||||
final presenceUserId = this.presenceUserId;
|
||||
final color =
|
||||
noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor;
|
||||
final container = Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: color,
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Material(
|
||||
color: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
side: border ?? BorderSide.none,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: noPic
|
||||
? textWidget
|
||||
: MxcImage(
|
||||
key: Key(mxContent.toString()),
|
||||
client: client,
|
||||
key: ValueKey(mxContent.toString()),
|
||||
cacheKey: '${mxContent}_$size',
|
||||
uri: mxContent,
|
||||
fit: BoxFit.cover,
|
||||
width: size,
|
||||
height: size,
|
||||
placeholder: (_) => textWidget,
|
||||
cacheKey: mxContent.toString(),
|
||||
placeholder: (_) => Center(
|
||||
child: Icon(
|
||||
Icons.person_2,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
size: size / 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PresenceBuilder(
|
||||
client: client,
|
||||
userId: presenceUserId,
|
||||
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.orange
|
||||
: Colors.grey;
|
||||
return Positioned(
|
||||
bottom: -3,
|
||||
right: -3,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: presenceBackgroundColor ??
|
||||
Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
if (presenceUserId != null)
|
||||
PresenceBuilder(
|
||||
client: client,
|
||||
userId: presenceUserId,
|
||||
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.orange
|
||||
: Colors.grey;
|
||||
return Positioned(
|
||||
bottom: -3,
|
||||
right: -3,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
color: presenceBackgroundColor ??
|
||||
Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
if (onTap == null) return container;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
|
||||
class MaxWidthBody extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final Widget child;
|
||||
final double maxWidth;
|
||||
final bool withFrame;
|
||||
final bool withScrolling;
|
||||
final EdgeInsets? innerPadding;
|
||||
|
||||
const MaxWidthBody({
|
||||
this.child,
|
||||
required this.child,
|
||||
this.maxWidth = 600,
|
||||
this.withFrame = true,
|
||||
this.withScrolling = true,
|
||||
this.innerPadding,
|
||||
super.key,
|
||||
|
|
@ -24,36 +21,35 @@ class MaxWidthBody extends StatelessWidget {
|
|||
return SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final paddingVal = max(0, (constraints.maxWidth - maxWidth) / 2);
|
||||
final hasPadding = paddingVal > 0;
|
||||
final padding = EdgeInsets.symmetric(
|
||||
vertical: hasPadding ? 32 : 0,
|
||||
horizontal: max(0, (constraints.maxWidth - maxWidth) / 2),
|
||||
);
|
||||
final childWithPadding = Padding(
|
||||
padding: padding,
|
||||
child: withFrame && hasPadding
|
||||
? Material(
|
||||
elevation:
|
||||
Theme.of(context).appBarTheme.scrolledUnderElevation ??
|
||||
4,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
shadowColor: Theme.of(context).appBarTheme.shadowColor,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
if (!withScrolling) {
|
||||
return Padding(
|
||||
padding: innerPadding ?? EdgeInsets.zero,
|
||||
child: childWithPadding,
|
||||
);
|
||||
}
|
||||
const desiredWidth = FluffyThemes.columnWidth * 1.5;
|
||||
final body = constraints.maxWidth <= desiredWidth
|
||||
? child
|
||||
: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
||||
),
|
||||
child: Material(
|
||||
elevation: Theme.of(context)
|
||||
.appBarTheme
|
||||
.scrolledUnderElevation ??
|
||||
4,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppConfig.borderRadius),
|
||||
shadowColor: Theme.of(context).appBarTheme.shadowColor,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!withScrolling) return body;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: innerPadding,
|
||||
physics: const ScrollPhysics(),
|
||||
child: childWithPadding,
|
||||
child: body,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
|
||||
class TwoColumnLayout extends StatelessWidget {
|
||||
final Widget mainView;
|
||||
final Widget sideView;
|
||||
|
|
@ -20,7 +22,8 @@ class TwoColumnLayout extends StatelessWidget {
|
|||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: const BoxDecoration(),
|
||||
width: 360.0 + (displayNavigationRail ? 64 : 0),
|
||||
width: FluffyThemes.columnWidth +
|
||||
(displayNavigationRail ? FluffyThemes.navRailWidth : 0),
|
||||
child: mainView,
|
||||
),
|
||||
Container(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class MxcImage extends StatefulWidget {
|
|||
final ThumbnailMethod thumbnailMethod;
|
||||
final Widget Function(BuildContext context)? placeholder;
|
||||
final String? cacheKey;
|
||||
final Client? client;
|
||||
|
||||
const MxcImage({
|
||||
this.uri,
|
||||
|
|
@ -38,6 +39,7 @@ class MxcImage extends StatefulWidget {
|
|||
this.animationCurve = FluffyThemes.animationCurve,
|
||||
this.thumbnailMethod = ThumbnailMethod.scale,
|
||||
this.cacheKey,
|
||||
this.client,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -48,10 +50,10 @@ 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];
|
||||
}
|
||||
|
||||
Uint8List? get _imageData => widget.cacheKey == null
|
||||
? _imageDataNoCache
|
||||
: _imageDataCache[widget.cacheKey];
|
||||
|
||||
set _imageData(Uint8List? data) {
|
||||
if (data == null) return;
|
||||
|
|
@ -64,7 +66,7 @@ class _MxcImageState extends State<MxcImage> {
|
|||
bool? _isCached;
|
||||
|
||||
Future<void> _load() async {
|
||||
final client = Matrix.of(context).client;
|
||||
final client = widget.client ?? Matrix.of(context).client;
|
||||
final uri = widget.uri;
|
||||
final event = widget.event;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,19 +10,18 @@ import 'package:fluffychat/utils/fluffy_share.dart';
|
|||
import 'package:fluffychat/utils/url_launcher.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../utils/localized_exception_extension.dart';
|
||||
|
||||
class PublicRoomBottomSheet extends StatelessWidget {
|
||||
final String? roomAlias;
|
||||
final BuildContext outerContext;
|
||||
final PublicRoomsChunk? chunk;
|
||||
final VoidCallback? onRoomJoined;
|
||||
final List<String>? via;
|
||||
|
||||
PublicRoomBottomSheet({
|
||||
this.roomAlias,
|
||||
required this.outerContext,
|
||||
this.chunk,
|
||||
this.onRoomJoined,
|
||||
this.via,
|
||||
super.key,
|
||||
}) {
|
||||
assert(roomAlias != null || chunk != null);
|
||||
|
|
@ -39,8 +38,11 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
return chunk.roomId;
|
||||
}
|
||||
final roomId = chunk != null && knock
|
||||
? await client.knockRoom(chunk.roomId)
|
||||
: await client.joinRoom(roomAlias ?? chunk!.roomId);
|
||||
? await client.knockRoom(chunk.roomId, serverName: via)
|
||||
: await client.joinRoom(
|
||||
roomAlias ?? chunk!.roomId,
|
||||
serverName: via,
|
||||
);
|
||||
|
||||
if (!knock && client.getRoomById(roomId) == null) {
|
||||
await client.waitForRoomInSync(roomId);
|
||||
|
|
@ -52,7 +54,7 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
return;
|
||||
}
|
||||
if (result.error == null) {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop<bool>(true);
|
||||
// don't open the room if the joined room is a space
|
||||
if (chunk?.roomType != 'm.space' &&
|
||||
!client.getRoomById(result.result!)!.isSpace) {
|
||||
|
|
@ -64,17 +66,17 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
|
||||
bool _testRoom(PublicRoomsChunk r) => r.canonicalAlias == roomAlias;
|
||||
|
||||
Future<PublicRoomsChunk> _search(BuildContext context) async {
|
||||
Future<PublicRoomsChunk> _search() async {
|
||||
final chunk = this.chunk;
|
||||
if (chunk != null) return chunk;
|
||||
final query = await Matrix.of(context).client.queryPublicRooms(
|
||||
final query = await Matrix.of(outerContext).client.queryPublicRooms(
|
||||
server: roomAlias!.domain,
|
||||
filter: PublicRoomQueryFilter(
|
||||
genericSearchTerm: roomAlias,
|
||||
),
|
||||
);
|
||||
if (!query.chunk.any(_testRoom)) {
|
||||
throw (L10n.of(context)!.noRoomsFound);
|
||||
throw (L10n.of(outerContext)!.noRoomsFound);
|
||||
}
|
||||
return query.chunk.firstWhere(_testRoom);
|
||||
}
|
||||
|
|
@ -82,6 +84,7 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roomAlias = this.roomAlias ?? chunk?.canonicalAlias;
|
||||
final roomLink = roomAlias ?? chunk?.roomId;
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -108,41 +111,84 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
body: FutureBuilder<PublicRoomsChunk>(
|
||||
future: _search(context),
|
||||
future: _search(),
|
||||
builder: (context, snapshot) {
|
||||
final profile = snapshot.data;
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (profile == null)
|
||||
Container(
|
||||
height: 156,
|
||||
alignment: Alignment.center,
|
||||
color: Theme.of(context).secondaryHeaderColor,
|
||||
child: snapshot.hasError
|
||||
? Text(snapshot.error!.toLocalizedString(context))
|
||||
: const CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
else
|
||||
Center(
|
||||
child: Padding(
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Avatar(
|
||||
mxContent: profile.avatarUrl,
|
||||
name: profile.name ?? roomAlias,
|
||||
size: Avatar.defaultSize * 3,
|
||||
child: profile == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
)
|
||||
: Avatar(
|
||||
client: Matrix.of(outerContext).client,
|
||||
mxContent: profile.avatarUrl,
|
||||
name: profile.name ?? roomAlias,
|
||||
size: Avatar.defaultSize * 3,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: roomLink != null
|
||||
? () => FluffyShare.share(
|
||||
roomLink,
|
||||
context,
|
||||
copyOnly: true,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(
|
||||
Icons.copy_outlined,
|
||||
size: 14,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
label: Text(
|
||||
roomLink ?? '...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(
|
||||
Icons.groups_3_outlined,
|
||||
size: 14,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
label: Text(
|
||||
L10n.of(context)!.countParticipants(
|
||||
profile?.numJoinedMembers ?? 0,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _joinRoom(context),
|
||||
label: Text(
|
||||
chunk?.joinRule == 'knock' &&
|
||||
Matrix.of(context)
|
||||
Matrix.of(outerContext)
|
||||
.client
|
||||
.getRoomById(chunk!.roomId) ==
|
||||
null
|
||||
|
|
@ -151,36 +197,10 @@ class PublicRoomBottomSheet extends StatelessWidget {
|
|||
? L10n.of(context)!.joinSpace
|
||||
: L10n.of(context)!.joinRoom,
|
||||
),
|
||||
icon: const Icon(Icons.login_outlined),
|
||||
icon: const Icon(Icons.navigate_next),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(
|
||||
profile?.name ??
|
||||
roomAlias?.localpart ??
|
||||
chunk?.roomId.localpart ??
|
||||
L10n.of(context)!.chat,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${L10n.of(context)!.participant}: ${profile?.numJoinedMembers ?? 0}',
|
||||
),
|
||||
trailing: const Icon(Icons.account_box_outlined),
|
||||
),
|
||||
if (roomAlias != null)
|
||||
ListTile(
|
||||
title: Text(L10n.of(context)!.publicLink),
|
||||
subtitle: SelectableText(roomAlias),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
onPressed: () => FluffyShare.share(
|
||||
roomAlias,
|
||||
context,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profile?.topic?.isNotEmpty ?? false)
|
||||
ListTile(
|
||||
subtitle: SelectableLinkify(
|
||||
|
|
|
|||
|
|
@ -19,41 +19,32 @@ class UnreadRoomsBadge extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: Matrix.of(context)
|
||||
.client
|
||||
.onSync
|
||||
.stream
|
||||
.where((syncUpdate) => syncUpdate.hasRoomUpdate),
|
||||
builder: (context, _) {
|
||||
final unreadCount = Matrix.of(context)
|
||||
.client
|
||||
.rooms
|
||||
.where(filter)
|
||||
.where((r) => (r.isUnread || r.membership == Membership.invite))
|
||||
.length;
|
||||
return b.Badge(
|
||||
badgeStyle: b.BadgeStyle(
|
||||
badgeColor: Theme.of(context).colorScheme.primary,
|
||||
elevation: 4,
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
badgeContent: Text(
|
||||
unreadCount.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
showBadge: unreadCount != 0,
|
||||
badgeAnimation: const b.BadgeAnimation.scale(),
|
||||
position: badgePosition ?? b.BadgePosition.bottomEnd(),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
final unreadCount = Matrix.of(context)
|
||||
.client
|
||||
.rooms
|
||||
.where(filter)
|
||||
.where((r) => (r.isUnread || r.membership == Membership.invite))
|
||||
.length;
|
||||
return b.Badge(
|
||||
badgeStyle: b.BadgeStyle(
|
||||
badgeColor: Theme.of(context).colorScheme.primary,
|
||||
elevation: 4,
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
badgeContent: Text(
|
||||
unreadCount.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
showBadge: unreadCount != 0,
|
||||
badgeAnimation: const b.BadgeAnimation.scale(),
|
||||
position: badgePosition ?? b.BadgePosition.bottomEnd(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue