Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:material_ui/material_ui.dart';
class AdaptiveDialogAction extends StatelessWidget {
final VoidCallback? onPressed;
final bool autofocus;
final Widget child;
final bool bigButtons;
final BorderRadius? borderRadius;
static const BorderRadius topRadius = BorderRadius.only(
topLeft: Radius.circular(AppConfig.borderRadius),
topRight: Radius.circular(AppConfig.borderRadius),
bottomLeft: Radius.circular(2),
bottomRight: Radius.circular(2),
);
static const BorderRadius centerRadius = BorderRadius.all(Radius.circular(2));
static const BorderRadius bottomRadius = BorderRadius.only(
bottomLeft: Radius.circular(AppConfig.borderRadius),
bottomRight: Radius.circular(AppConfig.borderRadius),
topLeft: Radius.circular(2),
topRight: Radius.circular(2),
);
const AdaptiveDialogAction({
super.key,
required this.onPressed,
required this.child,
this.autofocus = false,
this.bigButtons = false,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
if (bigButtons) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius:
borderRadius ??
BorderRadius.circular(AppConfig.borderRadius),
),
backgroundColor: autofocus
? theme.colorScheme.primary
: theme.colorScheme.surfaceBright,
foregroundColor: autofocus
? theme.colorScheme.onPrimary
: theme.colorScheme.primary,
),
onPressed: onPressed,
autofocus: autofocus,
child: child,
),
),
);
}
return TextButton(
onPressed: onPressed,
autofocus: autofocus,
child: child,
);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return CupertinoDialogAction(
onPressed: onPressed,
isDefaultAction: autofocus,
child: child,
);
}
}
}
class AdaptiveDialogInkWell extends StatelessWidget {
final Widget child;
final VoidCallback? onTap;
final EdgeInsets padding;
const AdaptiveDialogInkWell({
super.key,
required this.onTap,
required this.child,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if ({TargetPlatform.iOS, TargetPlatform.macOS}.contains(theme.platform)) {
return CupertinoButton(
onPressed: onTap,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
color: theme.colorScheme.surfaceBright,
padding: padding,
child: child,
);
}
return Material(
color: onTap == null
? theme.colorScheme.surfaceContainer
: theme.colorScheme.surfaceBright,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
child: InkWell(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
onTap: onTap,
child: Padding(
padding: padding,
child: Center(child: child),
),
),
);
}
}
class AdaptiveIconTextButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
const AdaptiveIconTextButton({
super.key,
required this.label,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.secondary;
return Expanded(
child: AdaptiveDialogInkWell(
padding: EdgeInsets.all(8.0),
onTap: onTap,
child: Column(
mainAxisSize: .min,
children: [
Icon(icon, color: color),
Text(
label,
style: TextStyle(fontSize: 12, color: color),
maxLines: 1,
overflow: .ellipsis,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:material_ui/material_ui.dart';
class DialogTextField extends StatelessWidget {
final TextEditingController? controller;
final String? hintText;
final String? labelText;
final String? initialText;
final String? counterText;
final String? prefixText;
final String? suffixText;
final String? errorText;
final bool obscureText;
final bool isDestructive = false;
final int? minLines;
final int? maxLines;
final TextInputType? keyboardType;
final int? maxLength;
final bool autocorrect = true;
final bool readOnly;
final TextStyle? textStyle;
const DialogTextField({
super.key,
this.hintText,
this.labelText,
this.initialText,
this.prefixText,
this.suffixText,
this.minLines,
this.maxLines,
this.keyboardType,
this.maxLength,
this.controller,
this.counterText,
this.errorText,
this.obscureText = false,
this.readOnly = false,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final prefixText = this.prefixText;
final suffixText = this.suffixText;
final errorText = this.errorText;
final theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return TextField(
readOnly: readOnly,
controller: controller,
obscureText: obscureText,
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
keyboardType: keyboardType,
autocorrect: autocorrect,
style: textStyle,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
borderSide: BorderSide(color: theme.dividerColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
borderSide: BorderSide(color: theme.dividerColor),
),
filled: true,
fillColor: theme.colorScheme.surfaceBright,
errorText: errorText,
hintText: hintText,
labelText: labelText,
prefixText: prefixText,
suffixText: suffixText,
counterText: counterText,
),
);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
final placeholder = labelText ?? hintText;
return Column(
children: [
SizedBox(
height: placeholder == null ? null : ((maxLines ?? 1) + 1) * 20,
child: CupertinoTextField(
controller: controller,
readOnly: readOnly,
obscureText: obscureText,
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
keyboardType: keyboardType,
autocorrect: autocorrect,
style: textStyle,
prefix: prefixText != null ? Text(prefixText) : null,
suffix: suffixText != null ? Text(suffixText) : null,
placeholder: placeholder,
),
),
if (errorText != null)
Text(
errorText,
style: TextStyle(fontSize: 11, color: theme.colorScheme.error),
textAlign: TextAlign.left,
),
],
);
}
}
}

View File

@@ -0,0 +1,308 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../config/themes.dart';
import '../../utils/url_launcher.dart';
import '../avatar.dart';
import '../future_loading_dialog.dart';
import '../hover_builder.dart';
import '../matrix.dart';
import '../mxc_image_viewer.dart';
import 'adaptive_dialog_action.dart';
class PublicRoomDialog extends StatelessWidget {
final String? roomAlias;
final PublishedRoomsChunk? chunk;
final List<String>? via;
const PublicRoomDialog({super.key, this.roomAlias, this.chunk, this.via});
Future<void> _joinRoom(BuildContext context) async {
final l10n = L10n.of(context);
final client = Matrix.of(context).client;
final chunk = this.chunk;
final knock = chunk?.joinRule == 'knock';
final result = await showFutureLoadingDialog<String>(
context: context,
future: () async {
if (chunk != null &&
client.getRoomById(chunk.roomId) != null &&
client.getRoomById(chunk.roomId)?.membership != Membership.leave) {
return chunk.roomId;
}
final roomId = chunk != null && knock
? await client.knockRoom(chunk.roomId, via: via)
: await client.joinRoom(roomAlias ?? chunk!.roomId, via: via);
if (!knock && client.getRoomById(roomId) == null) {
await client.waitForRoomInSync(roomId);
}
return roomId;
},
);
final roomId = result.result;
if (roomId == null) return;
if (!context.mounted) return;
if (knock && client.getRoomById(roomId) == null) {
Navigator.of(context).pop<bool>(true);
await showOkAlertDialog(
context: context,
title: l10n.youHaveKnocked,
message: l10n.pleaseWaitUntilInvited,
);
return;
}
if (result.error != null) return;
if (!context.mounted) return;
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) {
context.go('/rooms/$roomId');
} else {
context.go('/rooms?spaceId=$roomId');
}
return;
}
bool _testRoom(PublishedRoomsChunk r) => r.canonicalAlias == roomAlias;
Future<PublishedRoomsChunk> _search(BuildContext context) async {
final l10n = L10n.of(context);
final chunk = this.chunk;
if (chunk != null) return chunk;
final query = await Matrix.of(context).client.queryPublicRooms(
server: roomAlias!.domain,
filter: PublicRoomQueryFilter(genericSearchTerm: roomAlias),
);
if (!query.chunk.any(_testRoom)) {
throw (l10n.noRoomsFound);
}
return query.chunk.firstWhere(_testRoom);
}
@override
Widget build(BuildContext context) {
final roomAlias = this.roomAlias ?? chunk?.canonicalAlias;
final roomLink = roomAlias ?? chunk?.roomId;
var copied = false;
return AlertDialog.adaptive(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: FutureBuilder<PublishedRoomsChunk>(
future: _search(context),
builder: (context, snapshot) {
final theme = Theme.of(context);
final profile = snapshot.data;
final avatar = profile?.avatarUrl;
final topic = profile?.topic;
return SingleChildScrollView(
child: Column(
spacing: 8,
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Row(
spacing: 12,
children: [
Avatar(
mxContent: avatar,
name: profile?.name ?? roomLink,
size: Avatar.defaultSize * 1.5,
onTap: avatar != null
? () => showDialog(
context: context,
builder: (_) => MxcImageViewer(avatar),
)
: null,
),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(
profile?.name ??
roomLink ??
profile?.roomId ??
' - ',
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
if (roomLink != null)
HoverBuilder(
builder: (context, hovered) => StatefulBuilder(
builder: (context, setState) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: roomLink),
);
setState(() {
copied = true;
});
},
child: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(
right: 4.0,
),
child: AnimatedScale(
duration: FluffyThemes
.animationDuration,
curve: FluffyThemes
.animationCurve,
scale: hovered
? 1.33
: copied
? 1.25
: 1.0,
child: Icon(
copied
? Icons.check_circle
: Icons.copy,
size: 12,
color: copied
? Colors.green
: null,
),
),
),
),
TextSpan(text: roomLink),
],
style: theme.textTheme.bodyMedium
?.copyWith(fontSize: 10),
),
maxLines: 1,
textAlign: TextAlign.center,
),
),
),
),
),
if (profile?.numJoinedMembers != null)
Text(
L10n.of(context).countParticipants(
profile?.numJoinedMembers ?? 0,
),
style: const TextStyle(fontSize: 10),
textAlign: TextAlign.center,
),
],
),
),
],
),
if (topic != null && topic.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 200),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
child: SelectableLinkify(
text: topic,
textScaleFactor: MediaQuery.textScalerOf(
context,
).scale(1),
textAlign: .start,
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
),
),
Row(
mainAxisAlignment: .spaceBetween,
spacing: 4,
children: [
AdaptiveIconTextButton(
label: L10n.of(context).report,
icon: Icons.gavel_outlined,
onTap: () async {
Navigator.of(context).pop();
final reason = await showTextInputDialog(
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).report,
cancelLabel: L10n.of(context).cancel,
hintText: L10n.of(context).reason,
);
if (reason == null || reason.isEmpty) return;
if (!context.mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.reportRoom(
chunk?.roomId ?? roomAlias!,
reason,
),
);
},
),
AdaptiveIconTextButton(
label: L10n.of(context).copy,
icon: Icons.copy_outlined,
onTap: () =>
Clipboard.setData(ClipboardData(text: roomLink!)),
),
AdaptiveIconTextButton(
label: L10n.of(context).share,
icon: Icons.adaptive.share,
onTap: () => TatuShare.share(
'https://matrix.to/#/$roomLink',
context,
),
),
],
),
AdaptiveDialogInkWell(
onTap: () => _joinRoom(context),
child: Text(
chunk?.joinRule == 'knock' &&
Matrix.of(
context,
).client.getRoomById(chunk!.roomId) ==
null
? L10n.of(context).knock
: chunk?.roomType == 'm.space'
? L10n.of(context).joinSpace
: L10n.of(context).joinRoom,
style: TextStyle(color: theme.colorScheme.secondary),
),
),
],
),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:material_ui/material_ui.dart';
Future<T?> showModalActionPopup<T>({
required BuildContext context,
required List<AdaptiveModalAction<T>> actions,
String? title,
String? message,
String? cancelLabel,
bool useRootNavigator = true,
}) {
final theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
case TargetPlatform.linux:
return showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: useRootNavigator,
context: context,
clipBehavior: Clip.hardEdge,
constraints: BoxConstraints(
maxWidth: 512,
maxHeight: MediaQuery.sizeOf(context).height - 32,
),
builder: (context) => ListView(
shrinkWrap: true,
children: [
if (title != null || message != null) ...[
ListTile(
title: title == null
? null
: Text(title, style: theme.textTheme.labelSmall),
subtitle: message == null ? null : Text(message),
),
const Divider(height: 1),
],
...actions.map(
(action) => ListTile(
leading: action.icon,
title: Text(
action.label,
maxLines: 1,
style: action.isDestructive
? TextStyle(
color: theme.colorScheme.error,
fontWeight: action.isDefaultAction
? FontWeight.bold
: null,
)
: null,
),
onTap: () => Navigator.of(context).pop<T>(action.value),
),
),
if (cancelLabel != null) ...[
const Divider(height: 1),
ListTile(
title: Text(cancelLabel),
onTap: () => Navigator.of(context).pop(null),
),
],
],
),
);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return showCupertinoModalPopup<T>(
context: context,
useRootNavigator: useRootNavigator,
builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 512),
child: CupertinoActionSheet(
title: title == null ? null : Text(title),
message: message == null ? null : Text(message),
cancelButton: cancelLabel == null
? null
: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(null),
child: Text(cancelLabel),
),
actions: actions
.map(
(action) => CupertinoActionSheetAction(
isDestructiveAction: action.isDestructive,
isDefaultAction: action.isDefaultAction,
onPressed: () => Navigator.of(context).pop<T>(action.value),
child: Text(action.label, maxLines: 1),
),
)
.toList(),
),
),
);
}
}
class AdaptiveModalAction<T> {
final String label;
final T value;
Icon? icon;
final bool isDefaultAction;
final bool isDestructive;
AdaptiveModalAction({
required this.label,
required this.value,
this.icon,
this.isDefaultAction = false,
this.isDestructive = false,
});
}

View File

@@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
enum OkCancelResult { ok, cancel }
Future<OkCancelResult?> showOkCancelAlertDialog({
required BuildContext context,
required String title,
String? message,
String? okLabel,
String? cancelLabel,
bool isDestructive = false,
bool useRootNavigator = true,
}) => showAdaptiveDialog<OkCancelResult>(
context: context,
useRootNavigator: useRootNavigator,
builder: (context) => AlertDialog.adaptive(
title: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Text(title),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: message == null
? null
: SelectableLinkify(
text: message,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decorationColor: Theme.of(context).colorScheme.primary,
),
options: const LinkifyOptions(humanize: false),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
actions: [
AdaptiveDialogAction(
key: Key('ok_cancel_alert_dialog_cancel_button'),
onPressed: () =>
Navigator.of(context).pop<OkCancelResult>(OkCancelResult.cancel),
child: Text(cancelLabel ?? L10n.of(context).cancel),
),
AdaptiveDialogAction(
key: Key('ok_cancel_alert_dialog_ok_button'),
onPressed: () =>
Navigator.of(context).pop<OkCancelResult>(OkCancelResult.ok),
autofocus: true,
child: Text(
okLabel ?? L10n.of(context).ok,
style: isDestructive
? TextStyle(color: Theme.of(context).colorScheme.error)
: null,
),
),
],
),
);
Future<OkCancelResult?> showOkAlertDialog({
required BuildContext context,
required String title,
String? message,
String? okLabel,
bool useRootNavigator = true,
}) => showAdaptiveDialog<OkCancelResult>(
context: context,
useRootNavigator: useRootNavigator,
builder: (context) => AlertDialog.adaptive(
title: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Text(title),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: message == null
? null
: SelectableLinkify(
text: message,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decorationColor: Theme.of(context).colorScheme.primary,
),
options: const LinkifyOptions(humanize: false),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
actions: [
AdaptiveDialogAction(
onPressed: () =>
Navigator.of(context).pop<OkCancelResult>(OkCancelResult.ok),
autofocus: true,
child: Text(okLabel ?? L10n.of(context).close),
),
],
),
);

View File

@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
Future<String?> showTextInputDialog({
required BuildContext context,
required String title,
String? message,
String? okLabel,
String? cancelLabel,
bool useRootNavigator = true,
String? hintText,
String? labelText,
String? initialText,
String? prefixText,
String? suffixText,
bool obscureText = false,
bool isDestructive = false,
int? minLines,
int? maxLines,
String? Function(String input)? validator,
TextInputType? keyboardType,
int? maxLength,
bool autocorrect = true,
}) {
// TODO: Dispose this somewhere
final controller = TextEditingController(text: initialText);
return showAdaptiveDialog<String>(
context: context,
useRootNavigator: useRootNavigator,
builder: (context) {
final error = ValueNotifier<String?>(null);
return AlertDialog.adaptive(
title: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Text(title),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Column(
mainAxisSize: .min,
children: [
if (message != null)
SelectableLinkify(
text: message,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decorationColor: Theme.of(context).colorScheme.primary,
),
options: const LinkifyOptions(humanize: false),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
const SizedBox(height: 16),
ValueListenableBuilder<String?>(
valueListenable: error,
builder: (context, error, _) {
return DialogTextField(
hintText: hintText,
errorText: error,
labelText: labelText,
controller: controller,
initialText: initialText,
prefixText: prefixText,
suffixText: suffixText,
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
keyboardType: keyboardType,
obscureText: obscureText,
);
},
),
],
),
),
actions: [
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop(null),
child: Text(cancelLabel ?? L10n.of(context).cancel),
),
AdaptiveDialogAction(
onPressed: () {
final input = controller.text;
final errorText = validator?.call(input);
if (errorText != null) {
error.value = errorText;
return;
}
Navigator.of(context).pop<String>(input);
},
autofocus: true,
child: Text(
okLabel ?? L10n.of(context).ok,
style: isDestructive
? TextStyle(color: Theme.of(context).colorScheme.error)
: null,
),
),
],
);
},
);
}

View File

@@ -0,0 +1,274 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/date_time_extension.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/presence_builder.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../utils/url_launcher.dart';
import '../future_loading_dialog.dart';
import '../hover_builder.dart';
import '../matrix.dart';
import '../mxc_image_viewer.dart';
// ignore: unused_import
class UserDialog extends StatelessWidget {
static Future<void> show({
required BuildContext context,
required Profile profile,
bool noProfileWarning = false,
}) => showAdaptiveDialog(
context: context,
barrierDismissible: true,
builder: (context) =>
UserDialog(profile, noProfileWarning: noProfileWarning),
);
final Profile profile;
final bool noProfileWarning;
const UserDialog(this.profile, {this.noProfileWarning = false, super.key});
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final directChatRoomId = client.getDirectChatFromUserId(profile.userId);
final displayname =
profile.displayName ??
profile.userId.localpart ??
L10n.of(context).user;
var copied = false;
final theme = Theme.of(context);
final avatar = profile.avatarUrl;
return AlertDialog.adaptive(
content: PresenceBuilder(
userId: profile.userId,
client: Matrix.of(context).client,
builder: (context, presence) {
if (presence == null) return const SizedBox.shrink();
final statusMsg = presence.statusMsg;
final lastActiveTimestamp = presence.lastActiveTimestamp;
final presenceText = presence.currentlyActive == true
? L10n.of(context).currentlyActive
: lastActiveTimestamp != null
? L10n.of(
context,
).lastActiveAgo(lastActiveTimestamp.localizedTimeShort(context))
: null;
return Column(
spacing: 8,
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Row(
spacing: 12,
children: [
Avatar(
mxContent: avatar,
name: displayname,
size: Avatar.defaultSize * 1.5,
onTap: avatar != null
? () => showDialog(
context: context,
builder: (_) => MxcImageViewer(avatar),
)
: null,
),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(
displayname,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
HoverBuilder(
builder: (context, hovered) => StatefulBuilder(
builder: (context, setState) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: profile.userId),
);
setState(() {
copied = true;
});
},
child: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(
right: 4.0,
),
child: AnimatedScale(
duration:
FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
scale: hovered
? 1.33
: copied
? 1.25
: 1.0,
child: Icon(
copied
? Icons.check_circle
: Icons.copy,
size: 12,
color: copied
? Colors.green
: null,
),
),
),
),
TextSpan(text: profile.userId),
],
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 10,
),
),
maxLines: 1,
overflow: .ellipsis,
textAlign: TextAlign.center,
),
),
),
),
),
if (presenceText != null)
Text(
presenceText,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 10,
),
),
],
),
),
],
),
if (statusMsg != null)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: 200),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
child: SelectableLinkify(
text: statusMsg,
textScaleFactor: MediaQuery.textScalerOf(
context,
).scale(1),
textAlign: TextAlign.start,
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
),
),
Row(
mainAxisAlignment: .spaceBetween,
spacing: 4,
children: [
AdaptiveIconTextButton(
label: L10n.of(context).block,
icon: Icons.block_outlined,
onTap: client.userID == profile.userId
? null
: () {
final router = GoRouter.of(context);
Navigator.of(context).pop();
router.go(
'/rooms/settings/security/ignorelist',
extra: profile.userId,
);
},
),
AdaptiveIconTextButton(
label: L10n.of(context).report,
icon: Icons.gavel_outlined,
onTap: client.userID == profile.userId
? null
: () async {
Navigator.of(context).pop();
final reason = await showTextInputDialog(
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).report,
cancelLabel: L10n.of(context).cancel,
hintText: L10n.of(context).reason,
);
if (reason == null || reason.isEmpty) return;
if (!context.mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(
context,
).client.reportUser(profile.userId, reason),
);
},
),
AdaptiveIconTextButton(
label: L10n.of(context).share,
icon: Icons.adaptive.share,
onTap: () => TatuShare.share(
'https://matrix.to/#/${profile.userId}',
context,
),
),
],
),
AdaptiveDialogInkWell(
onTap: client.userID == profile.userId
? null
: () async {
final router = GoRouter.of(context);
final roomIdResult = await showFutureLoadingDialog(
context: context,
future: () => client.startDirectChat(profile.userId),
);
final roomId = roomIdResult.result;
if (roomId == null) return;
if (context.mounted) Navigator.of(context).pop();
router.go('/rooms/$roomId');
},
child: Text(
directChatRoomId == null
? L10n.of(context).createNewChat
: L10n.of(context).sendAMessage,
style: TextStyle(color: theme.colorScheme.secondary),
),
),
],
);
},
),
);
}
}

146
lib/widgets/app_lock.dart Normal file
View File

@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/widgets/lock_screen.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:material_ui/material_ui.dart';
import 'package:provider/provider.dart';
class AppLockWidget extends StatefulWidget {
const AppLockWidget({
required this.child,
required this.pincode,
required this.useBiometrics,
required this.isLoggedIn,
super.key,
});
final bool isLoggedIn, useBiometrics;
final String? pincode;
final Widget child;
@override
State<AppLockWidget> createState() => AppLock();
}
class AppLock extends State<AppLockWidget> with WidgetsBindingObserver {
String? _pincode;
bool _isLocked = false;
bool _useBiometrics = false;
bool _triedAutoBiometrics = false;
bool _paused = false;
bool get isActive =>
_pincode != null &&
int.tryParse(_pincode!) != null &&
_pincode!.length >= 4 &&
!_paused;
bool get useBiometrics => _useBiometrics;
@override
void initState() {
_useBiometrics = widget.useBiometrics;
_pincode = widget.pincode;
_isLocked = isActive;
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback(_checkLoggedIn);
if (isActive && useBiometrics) unlockWithBiometrics();
}
Future<void> _checkLoggedIn(_) async {
if (widget.isLoggedIn) return;
await changePincode(null);
setState(() {
_isLocked = false;
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (isActive && state == AppLifecycleState.hidden && !_isLocked) {
showLockScreen();
}
if (_isLocked &&
state == AppLifecycleState.resumed &&
useBiometrics &&
!_triedAutoBiometrics) {
unlockWithBiometrics();
}
}
bool get isLocked => _isLocked;
Future<void> changeUseBiometrics(bool useBiometrics) async {
await const FlutterSecureStorage().write(
key: 'chat.fluffy.use_biometrics',
value: useBiometrics.toString(),
);
_useBiometrics = useBiometrics;
return;
}
Future<void> changePincode(String? pincode) async {
await const FlutterSecureStorage().write(
key: 'chat.fluffy.app_lock',
value: pincode,
);
_pincode = pincode;
return;
}
Future<bool> unlockWithBiometrics() async {
_triedAutoBiometrics = true;
final localAuth = LocalAuthentication();
final unlocked = await localAuth.authenticate(
localizedReason: 'Please authenticate to unlock the app.',
persistAcrossBackgrounding: true,
biometricOnly: true,
);
if (unlocked) {
setState(() {
_isLocked = false;
_triedAutoBiometrics = false;
});
}
return unlocked;
}
bool unlock(String pincode) {
final isCorrect = pincode == _pincode;
if (isCorrect) {
setState(() {
_isLocked = false;
});
}
return isCorrect;
}
void showLockScreen() => setState(() {
_isLocked = true;
});
Future<T> pauseWhile<T>(Future<T> future) async {
_paused = true;
try {
return await future;
} finally {
_paused = false;
}
}
static AppLock of(BuildContext context) =>
Provider.of<AppLock>(context, listen: false);
@override
Widget build(BuildContext context) => Provider<AppLock>(
create: (_) => this,
child: Stack(
fit: StackFit.expand,
children: [widget.child, if (isLocked) const LockScreen()],
),
);
}

170
lib/widgets/avatar.dart Normal file
View File

@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/tatu_palette.dart';
import 'package:tatchat/utils/string_color.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:tatchat/widgets/presence_builder.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class Avatar extends StatelessWidget {
final Uri? mxContent;
final String? name;
final double size;
final void Function()? onTap;
static const double defaultSize = 48;
final Client? client;
final String? presenceUserId;
final Color? presenceBackgroundColor;
final BorderRadius? borderRadius;
final IconData? icon;
final ShapeBorder? shapeBorder;
final Color? backgroundColor;
final Color? textColor;
const Avatar({
this.mxContent,
this.name,
this.size = defaultSize,
this.onTap,
this.client,
this.presenceUserId,
this.presenceBackgroundColor,
this.borderRadius,
this.shapeBorder,
this.icon,
this.backgroundColor,
this.textColor,
super.key,
});
String _calcFallbackLetters() {
final name = this.name?.trim();
if (name == null || name.isEmpty) return '@';
final words = name.split(' ');
if (words.length <= 1) return name.substring(0, 1);
return '${words.first.substring(0, 1)}${words.last.substring(0, 1)}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fallbackLetters = _calcFallbackLetters();
final noPic =
mxContent == null ||
mxContent.toString().isEmpty ||
mxContent.toString() == 'null';
final borderRadius = this.borderRadius ?? BorderRadius.circular(size / 2);
final presenceUserId = this.presenceUserId;
final container = Stack(
children: [
SizedBox(
width: size,
height: size,
child: Material(
color: theme.brightness == Brightness.light
? Colors.white
: Colors.black,
shape:
shapeBorder ??
RoundedSuperellipseBorder(
borderRadius: borderRadius,
side: BorderSide.none,
),
clipBehavior: Clip.antiAlias,
child: MxcImage(
client: client,
borderRadius: borderRadius,
key: ValueKey(mxContent.toString()),
cacheKey: '${mxContent}_$size',
uri: mxContent,
fit: BoxFit.cover,
width: size,
height: size,
placeholder: (_) => noPic
? Container(
decoration: BoxDecoration(
color:
backgroundColor ??
fallbackLetters.colorScheme.primaryContainer,
),
alignment: Alignment.center,
child: Text(
fallbackLetters,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'RobotoMono',
color:
textColor ??
fallbackLetters.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
fontSize: (size / 2.5).roundToDouble(),
),
),
)
: Center(
child: Icon(
Icons.person_2,
color: theme.colorScheme.tertiary,
size: size / 1.5,
),
),
),
),
),
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
? TatuPalette.brandEmerald
: presence.presence.isUnavailable
? Colors.orange
: Colors.grey;
return Positioned(
bottom: -3,
right: -3,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: presenceBackgroundColor ?? theme.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.colorScheme.surface,
),
),
),
),
);
},
),
],
);
if (onTap == null) return container;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(onTap: onTap, child: container),
);
}
}

View File

@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:blurhash_dart/blurhash_dart.dart' as b;
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as image;
import 'package:material_ui/material_ui.dart';
class BlurHash extends StatefulWidget {
final double width;
final double height;
final String blurhash;
final BoxFit fit;
const BlurHash({
super.key,
String? blurhash,
required this.width,
required this.height,
this.fit = BoxFit.cover,
}) : blurhash = blurhash ?? 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
@override
State<BlurHash> createState() => _BlurHashState();
}
class _BlurHashState extends State<BlurHash> {
Uint8List? _data;
static Future<Uint8List> getBlurhashData(BlurhashData blurhashData) async {
final blurhash = b.BlurHash.decode(blurhashData.hsh);
final img = blurhash.toImage(blurhashData.w, blurhashData.h);
return Uint8List.fromList(image.encodePng(img));
}
Future<Uint8List?> _computeBlurhashData() async {
if (_data != null) return _data!;
final ratio = widget.width / widget.height;
var width = 32;
var height = 32;
if (ratio > 1.0) {
height = (width / ratio).round();
} else {
width = (height * ratio).round();
}
return _data ??= await compute(
getBlurhashData,
BlurhashData(hsh: widget.blurhash, w: width, h: height),
);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _computeBlurhashData(),
initialData: _data,
builder: (context, snapshot) {
final data = snapshot.data;
if (data == null) {
return Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.onInverseSurface,
);
}
return Image.memory(
data,
fit: widget.fit,
width: widget.width,
height: widget.height,
);
},
);
}
}
class BlurhashData {
final String hsh;
final int w;
final int h;
const BlurhashData({required this.hsh, required this.w, required this.h});
factory BlurhashData.fromJson(Map<String, dynamic> json) =>
BlurhashData(hsh: json['hsh'], w: json['w'], h: json['h']);
Map<String, dynamic> toJson() => {'hsh': hsh, 'w': w, 'h': h};
}

View File

@@ -0,0 +1,145 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'matrix.dart';
enum ChatPopupMenuActions { details, encryption, leave, search }
class ChatSettingsPopupMenu extends StatefulWidget {
final Room room;
final bool displayChatDetails;
const ChatSettingsPopupMenu(this.room, this.displayChatDetails, {super.key});
@override
ChatSettingsPopupMenuState createState() => ChatSettingsPopupMenuState();
}
class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
StreamSubscription? notificationChangeSub;
@override
void dispose() {
notificationChangeSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
notificationChangeSub ??= Matrix.of(context).client.onSync.stream
.where(
(syncUpdate) =>
syncUpdate.accountData?.any(
(accountData) => accountData.type == 'm.push_rules',
) ??
false,
)
.listen((u) => setState(() {}));
return Stack(
alignment: Alignment.center,
children: [
const SizedBox.shrink(),
PopupMenuButton<ChatPopupMenuActions>(
useRootNavigator: true,
onSelected: (choice) async {
switch (choice) {
case ChatPopupMenuActions.leave:
final l10n = L10n.of(context);
final router = GoRouter.of(context);
final confirmed = await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
message: l10n.archiveRoomDescription,
okLabel: l10n.leave,
cancelLabel: l10n.cancel,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
if (!context.mounted) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => widget.room.leave(),
);
if (result.error == null) {
router.go('/rooms');
}
break;
case ChatPopupMenuActions.details:
_showChatDetails();
break;
case ChatPopupMenuActions.search:
context.go('/rooms/${widget.room.id}/search');
break;
case ChatPopupMenuActions.encryption:
context.go('/rooms/${widget.room.id}/encryption');
break;
}
},
itemBuilder: (BuildContext context) => [
if (widget.displayChatDetails)
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.details,
child: Row(
children: [
const Icon(Icons.info_outline_rounded),
const SizedBox(width: 12),
Text(L10n.of(context).chatDetails),
],
),
),
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.search,
child: Row(
children: [
const Icon(Icons.search_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).search),
],
),
),
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.encryption,
child: Row(
children: [
const Icon(Icons.lock_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).encryption),
],
),
),
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.leave,
child: Row(
children: [
const Icon(Icons.delete_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).leave),
],
),
),
],
),
],
);
}
void _showChatDetails() {
if (GoRouterState.of(context).uri.path.endsWith('/details')) {
context.go('/rooms/${widget.room.id}');
} else {
context.go('/rooms/${widget.room.id}/details');
}
}
}

View File

@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ConfigViewer extends StatefulWidget {
const ConfigViewer({super.key});
@override
State<ConfigViewer> createState() => _ConfigViewerState();
}
class _ConfigViewerState extends State<ConfigViewer> {
String _searchQuery = '';
Future<void> _changeSetting(
AppSettings appSetting,
SharedPreferences store,
String initialValue,
) async {
if (appSetting is AppSettings<bool>) {
await appSetting.setItem(!(initialValue == 'true'));
setState(() {});
return;
}
final value = await showTextInputDialog(
context: context,
title: appSetting.name,
hintText: appSetting.defaultValue.toString(),
initialText: initialValue,
);
if (value == null) return;
if (appSetting is AppSettings<String>) {
await appSetting.setItem(value);
}
if (appSetting is AppSettings<int>) {
await appSetting.setItem(int.parse(value));
}
if (appSetting is AppSettings<double>) {
await appSetting.setItem(double.parse(value));
}
setState(() {});
}
Future<void> _reset() async {
await AppSettings.reset();
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final normalizedQuery = _searchQuery.trim().toLowerCase();
final filteredSettings = AppSettings.values
.where((setting) {
if (normalizedQuery.isEmpty) return true;
return setting.name.toLowerCase().contains(normalizedQuery);
})
.toList(growable: false);
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).advancedConfigurations),
leading: BackButton(onPressed: () => context.go('/')),
actions: [
TextButton(onPressed: _reset, child: Text(L10n.of(context).reset)),
],
),
body: Column(
crossAxisAlignment: .stretch,
children: [
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
color: theme.colorScheme.errorContainer,
child: Text(
'Changing configs by hand is untested! Use without any warranty!',
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
onChanged: (value) => setState(() => _searchQuery = value),
decoration: InputDecoration(
hintText: 'Search config key',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withAlpha(
128,
),
),
),
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: filteredSettings.length,
itemBuilder: (context, i) {
final store = Matrix.of(context).store;
final appSetting = filteredSettings[i];
var value = '';
if (appSetting is AppSettings<String>) {
value = appSetting.value;
}
if (appSetting is AppSettings<int>) {
value = appSetting.value.toString();
}
if (appSetting is AppSettings<bool>) {
return SwitchListTile.adaptive(
title: Text(appSetting.name),
subtitle: Text(value),
value: appSetting.value,
onChanged: (value) =>
_changeSetting(appSetting, store, (!value).toString()),
);
}
if (appSetting is AppSettings<double>) {
value = appSetting.value.toString();
}
return ListTile(
title: Text(appSetting.name),
subtitle: Text(value),
onTap: () => _changeSetting(appSetting, store, value),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:async/async.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix_api_lite/utils/logs.dart';
/// Displays a loading dialog which reacts to the given [future]. The dialog
/// will be dismissed and the value will be returned when the future completes.
/// If an error occured, then [onError] will be called and this method returns
/// null.
Future<Result<T>> showFutureLoadingDialog<T>({
required BuildContext context,
Future<T> Function()? future,
Future<T> Function(void Function(double?) setProgress)? futureWithProgress,
String? title,
String? backLabel,
bool barrierDismissible = false,
bool delay = true,
ExceptionContext? exceptionContext,
bool ignoreError = false,
}) async {
assert(future != null || futureWithProgress != null);
final onProgressStream = StreamController<double?>();
final futureExec =
futureWithProgress?.call(onProgressStream.add) ?? future!();
final resultFuture = ResultFuture(futureExec);
if (delay) {
var i = 3;
while (i > 0) {
final result = resultFuture.result;
if (result != null) {
if (result.isError) break;
return result;
}
await Future.delayed(const Duration(milliseconds: 100));
i--;
}
}
if (!context.mounted) {
Logs().e(
'Unable to show loading dialog!',
Exception('The BuildContext is not mounted!'),
StackTrace.current,
);
return Result.capture(futureExec);
}
final result = await showAdaptiveDialog<Result<T>>(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) => LoadingDialog<T>(
future: futureExec,
title: title,
backLabel: backLabel,
exceptionContext: exceptionContext,
onProgressStream: onProgressStream.stream,
),
);
return result ??
Result.error(Exception('FutureDialog canceled'), StackTrace.current);
}
class LoadingDialog<T> extends StatefulWidget {
final String? title;
final String? backLabel;
final Future<T> future;
final ExceptionContext? exceptionContext;
final Stream<double?> onProgressStream;
const LoadingDialog({
super.key,
required this.future,
this.title,
this.backLabel,
this.exceptionContext,
required this.onProgressStream,
});
@override
LoadingDialogState<T> createState() => LoadingDialogState<T>();
}
class LoadingDialogState<T> extends State<LoadingDialog> {
Object? exception;
StackTrace? stackTrace;
@override
void initState() {
super.initState();
widget.future.then(
(result) {
if (!mounted) return;
if (!Navigator.of(context).canPop()) return;
Navigator.of(context).pop<Result<T>>(Result.value(result));
},
onError: (e, s) => setState(() {
exception = e;
stackTrace = s;
}),
);
}
@override
Widget build(BuildContext context) {
final exception = this.exception;
final titleLabel = exception != null
? exception.toLocalizedString(context, widget.exceptionContext)
: widget.title ?? L10n.of(context).loadingPleaseWait;
return AlertDialog.adaptive(
title: exception == null
? null
: Icon(
Icons.error_outline_outlined,
color: Theme.of(context).colorScheme.error,
size: 48,
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256),
child: Row(
crossAxisAlignment: .center,
children: [
if (exception == null) ...[
StreamBuilder(
stream: widget.onProgressStream,
builder: (context, snapshot) =>
CircularProgressIndicator.adaptive(value: snapshot.data),
),
const SizedBox(width: 20),
],
Expanded(
child: Text(
titleLabel,
maxLines: 4,
textAlign: exception == null ? TextAlign.left : null,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
actions: exception == null
? null
: [
AdaptiveDialogAction(
onPressed: () => Navigator.of(
context,
).pop<Result<T>>(Result.error(exception, stackTrace)),
child: Text(widget.backLabel ?? L10n.of(context).close),
),
],
);
}
}
extension DeprecatedApiAccessExtension<T> on Result<T> {
T? get result => asValue?.value;
Object? get error => asError?.error;
}

View File

@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:material_ui/material_ui.dart';
class HoverBuilder extends StatefulWidget {
final Widget Function(BuildContext context, bool hovered) builder;
const HoverBuilder({required this.builder, super.key});
@override
State<HoverBuilder> createState() => _HoverBuilderState();
}
class _HoverBuilderState extends State<HoverBuilder> {
bool hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => hovered
? null
: setState(() {
hovered = true;
}),
onExit: (_) => !hovered
? null
: setState(() {
hovered = false;
}),
child: widget.builder(context, hovered),
);
}
}

View File

@@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/pulsating_widget.dart';
import 'package:go_router/go_router.dart';
import 'package:just_audio/just_audio.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class IncomingCallDialog extends StatefulWidget {
final Event event;
const IncomingCallDialog({required this.event, super.key});
@override
State<IncomingCallDialog> createState() => _IncomingCallDialogState();
}
class _IncomingCallDialogState extends State<IncomingCallDialog> {
final AudioPlayer _player = AudioPlayer();
StreamSubscription? _onSync;
@override
void initState() {
_initAudioPlayer();
_onSync = widget.event.room.client.onSync.stream.listen((_) {
if (!mounted) return;
if (!widget.event.room.hasActiveMatrixRtcCall) {
Logs().i('The other party has ended the call');
Navigator.of(context).pop();
} else if (widget.event.room.getActiveMatrixRtcMembers().any(
(member) =>
member.senderId == widget.event.room.client.userID! &&
member.deviceId != widget.event.room.client.deviceID!,
)) {
Logs().i('User has accepted the call on a different device');
Navigator.of(context).pop();
}
});
final timeout =
widget.event.tryParseRtcNotificationContent()?.lifetime ??
RtcNotificationContent.defaultLifetime;
Future.delayed(timeout).then((_) {
if (!mounted) return;
Navigator.of(context).pop();
});
super.initState();
}
Future<void> _initAudioPlayer() async {
await _player.setAsset('assets/sounds/call_ring.mp3');
await _player.setLoopMode(.one);
await _player.play();
}
@override
void dispose() {
_onSync?.cancel();
_player.stop();
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final displayname = widget.event.room.getLocalizedDisplayname();
final avatar = widget.event.room.avatar;
final theme = Theme.of(context);
return AlertDialog(
title: Center(child: Text(L10n.of(context).incomingCall)),
content: Column(
mainAxisSize: .min,
spacing: 8,
children: [
PulsatingWidget(
spreadRadius: 10.0,
color: theme.colorScheme.primary,
child: Avatar(mxContent: avatar, name: displayname),
),
Text(displayname, textAlign: .center),
],
),
actionsAlignment: .spaceEvenly,
actions: [
IconButton(
style: IconButton.styleFrom(
fixedSize: Size.fromRadius(24),
elevation: theme.appBarTheme.elevation ?? 4,
shadowColor: theme.appBarTheme.shadowColor,
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
icon: Icon(Icons.call_end_outlined),
onPressed: () => context.pop(false),
),
IconButton(
style: IconButton.styleFrom(
fixedSize: Size.fromRadius(24),
elevation: theme.appBarTheme.elevation ?? 4,
shadowColor: theme.appBarTheme.shadowColor,
backgroundColor: Colors.green.shade700,
foregroundColor: Colors.white,
),
icon: Icon(Icons.call_outlined),
onPressed: () => context.pop(true),
),
],
);
}
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/pages/call/call_page.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:material_ui/material_ui.dart';
class CallOverlay extends StatelessWidget {
final Widget child;
const CallOverlay({required this.child, super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: Matrix.of(context).activeCallRoomId,
builder: (context, value, _) {
final roomId = value;
if (roomId == null) return child;
return ValueListenableBuilder(
valueListenable: Matrix.of(context).callPosition,
builder: (context, value, _) {
final callPosition = value;
final callFullScreen = callPosition == .fullScreen;
return Stack(
children: [
child,
AnimatedPositioned(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
top: callFullScreen
? 0
: callPosition == .top
? 86
: null,
left: callFullScreen ? 0 : null,
right: callFullScreen ? 0 : 8,
bottom: callFullScreen ? 0 : null,
height: callFullScreen ? null : 256,
width: callFullScreen ? null : 256,
child: Material(
elevation: callFullScreen ? 0 : 4,
clipBehavior: .hardEdge,
shape: callFullScreen
? null
: RoundedRectangleBorder(
side: BorderSide(color: Colors.grey),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
child: Navigator(
onDidRemovePage: (_) {
Matrix.of(context).activeCallRoomId.value = null;
},
pages: [MaterialPage(child: CallPage(roomId: roomId))],
),
),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:math';
import 'package:material_ui/material_ui.dart';
class EmptyPage extends StatelessWidget {
static const double _width = 400;
const EmptyPage({super.key});
@override
Widget build(BuildContext context) {
final width = min(MediaQuery.sizeOf(context).width, EmptyPage._width) / 2;
final theme = Theme.of(context);
return Scaffold(
// Add invisible appbar to make status bar on Android tablets bright.
appBar: AppBar(
automaticallyImplyLeading: false,
elevation: 0,
backgroundColor: Colors.transparent,
),
extendBodyBehindAppBar: true,
body: Container(
alignment: Alignment.center,
child: Image.asset(
'assets/logo/mini/logo_mono_mini.png',
color: theme.colorScheme.surfaceContainerHigh,
width: width,
height: width,
filterQuality: FilterQuality.medium,
),
),
);
}
}

View File

@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:material_ui/material_ui.dart';
import 'package:particles_network/particles_network.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LoginScaffold extends StatelessWidget {
final Widget body;
final AppBar? appBar;
final Widget? bottomNavigationBar;
const LoginScaffold({
super.key,
required this.body,
this.appBar,
this.bottomNavigationBar,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final isMobileMode = !FluffyThemes.isColumnModeByWidth(
constraints.maxWidth,
);
if (isMobileMode) {
return Scaffold(
key: const Key('LoginScaffold'),
appBar: appBar,
body: SafeArea(child: body),
bottomNavigationBar: bottomNavigationBar,
);
}
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.surfaceContainerLow,
theme.colorScheme.surfaceContainer,
theme.colorScheme.surfaceContainerHighest,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Stack(
children: [
if (!MediaQuery.disableAnimationsOf(context))
ParticleNetwork(
maxSpeed: 0.25,
particleColor: theme.colorScheme.primary,
lineColor: theme.colorScheme.secondary,
),
Column(
children: [
const SizedBox(height: 16),
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Material(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
clipBehavior: Clip.hardEdge,
elevation:
theme.appBarTheme.scrolledUnderElevation ?? 4,
shadowColor: theme.appBarTheme.shadowColor,
child: ConstrainedBox(
constraints: isMobileMode
? const BoxConstraints()
: const BoxConstraints(
maxWidth: 480,
maxHeight: 640,
),
child: Scaffold(
key: const Key('LoginScaffold'),
appBar: appBar,
body: SafeArea(child: body),
bottomNavigationBar: bottomNavigationBar,
),
),
),
),
),
),
const _PrivacyButtons(mainAxisAlignment: .center),
],
),
],
),
);
},
);
}
}
class _PrivacyButtons extends StatelessWidget {
final MainAxisAlignment mainAxisAlignment;
const _PrivacyButtons({required this.mainAxisAlignment});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final shadowTextStyle = TextStyle(color: theme.colorScheme.secondary);
return SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: mainAxisAlignment,
children: [
TextButton(
onPressed: () => launchUrlString(AppSettings.website.value),
child: Text(L10n.of(context).website, style: shadowTextStyle),
),
TextButton(
onPressed: () => launchUrlString(AppConfig.supportUrl),
child: Text(L10n.of(context).help, style: shadowTextStyle),
),
TextButton(
onPressed: () => launchUrlString(AppSettings.privacyPolicy.value),
child: Text(L10n.of(context).privacy, style: shadowTextStyle),
),
TextButton(
onPressed: () => PlatformInfos.showDialog(context),
child: Text(L10n.of(context).about, style: shadowTextStyle),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:material_ui/material_ui.dart';
class MaxWidthBody extends StatelessWidget {
final Widget child;
final double maxWidth;
final bool withScrolling;
final EdgeInsets? innerPadding;
const MaxWidthBody({
required this.child,
this.maxWidth = 600,
this.withScrolling = true,
this.innerPadding,
super.key,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final theme = Theme.of(context);
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(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
side: BorderSide(color: theme.dividerColor),
),
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: child,
),
),
),
);
if (!withScrolling) return body;
return SingleChildScrollView(
padding: innerPadding,
physics: const ScrollPhysics(),
child: body,
);
},
),
);
}
}

View File

@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/themes.dart';
import 'package:material_ui/material_ui.dart';
class TwoColumnLayout extends StatelessWidget {
final Widget mainView;
final Widget sideView;
final bool hasNavigationRail;
const TwoColumnLayout({
super.key,
required this.mainView,
required this.sideView,
this.hasNavigationRail = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ScaffoldMessenger(
child: Scaffold(
body: Row(
children: [
Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(),
width:
FluffyThemes.columnWidth +
(hasNavigationRail ? FluffyThemes.navRailWidth : 0),
child: mainView,
),
Container(width: 1.0, color: theme.dividerColor),
Expanded(child: ClipRRect(child: sideView)),
],
),
),
);
}
}

View File

@@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/client_download_content_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/notification_background_handler.dart';
import 'package:tatchat/utils/push_helper.dart';
import 'package:tatchat/widgets/tatu_chat_app.dart';
import 'package:tatchat/widgets/incoming_call_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
extension LocalNotificationsExtension on MatrixState {
static final html.AudioElement _audioPlayer = html.AudioElement()
..src = 'assets/assets/sounds/notification.mp3'
..load();
Future<void> showLocalNotification(Event event) async {
if (event.type == RtcNotificationContent.eventType &&
event.tryParseRtcNotificationContent()?.notificationType == .ring) {
final context =
TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
this.context;
showDialog<bool>(
context: context,
builder: (_) => IncomingCallDialog(event: event),
).then((joinCall) {
if (joinCall != true) return;
if (!context.mounted) return;
setActiveClient(event.room.client);
TatuChatApp.router.go('/rooms/${event.room.id}?action=call');
});
}
final l10n = L10n.of(context);
final roomId = event.room.id;
if (activeRoomId == roomId) {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
}
}
final title = event.room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
final body = await event.calcLocalizedBody(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix:
!event.room.isDirectChat ||
event.room.lastEvent?.senderId == client.userID,
plaintextBody: true,
hideReply: true,
hideEdit: true,
removeMarkdown: true,
);
final avatarUrl = event.room.avatar;
const size = 128;
const thumbnailMethod = ThumbnailMethod.crop;
if (avatarUrl != null) {
// Pre-cache so that we can later just set the thumbnail uri as icon:
try {
await client.downloadMxcCached(
avatarUrl,
width: size,
height: size,
thumbnailMethod: thumbnailMethod,
isThumbnail: true,
rounded: true,
);
} catch (e, s) {
Logs().d('Unable to pre-download avatar for web notification', e, s);
}
}
if (kIsWeb) {
final thumbnailUri = await avatarUrl?.getThumbnailUri(
client,
width: size,
height: size,
method: thumbnailMethod,
);
if (AppSettings.webNotificationSound.value) _audioPlayer.play();
html.Notification(
title,
body: body,
icon: thumbnailUri?.toString(),
tag: event.room.id,
);
return;
}
FlutterLocalNotificationsPlugin().show(
id: event.room.id.hashCode,
title: title,
body: body,
notificationDetails: NotificationDetails(
linux: LinuxNotificationDetails(
sound: ThemeLinuxSound('message-new-instant'),
actions: switch (event.type) {
EventTypes.Message ||
EventTypes.Encrypted ||
EventTypes.Sticker => [
LinuxNotificationAction(
key: FluffyChatNotificationActions.markAsRead.name,
label: l10n.markAsRead,
),
LinuxNotificationAction(
key: FluffyChatNotificationActions.mute.name,
label: l10n.mute,
),
],
RtcNotificationContent.eventType => [
LinuxNotificationAction(
key: FluffyChatNotificationActions.enterCall.name,
label: l10n.enterCall,
),
],
_ => [],
},
),
),
payload: FluffyChatPushPayload(
client.clientName,
event.room.id,
event.eventId,
).toString(),
);
}
}

View File

@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/app_lock.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:material_ui/material_ui.dart';
class LockScreen extends StatefulWidget {
const LockScreen({super.key});
@override
State<LockScreen> createState() => _LockScreenState();
}
class _LockScreenState extends State<LockScreen> {
String? _errorText;
int _coolDownSeconds = 5;
bool _inputBlocked = false;
final TextEditingController _textEditingController = TextEditingController();
Future<void> tryUnlockWithBiometrics() async {
setState(() {
_errorText = null;
});
final success = await AppLock.of(context).unlockWithBiometrics();
if (success) {
setState(() {
_inputBlocked = false;
_errorText = null;
});
return;
}
setState(() {
_errorText = L10n.of(context).wrongPinEntered(_coolDownSeconds);
_inputBlocked = true;
});
Future.delayed(Duration(seconds: _coolDownSeconds)).then((_) {
setState(() {
_inputBlocked = false;
_coolDownSeconds *= 2;
_errorText = null;
});
});
_textEditingController.clear();
}
Future<void> tryUnlock(String text) async {
text = text.trim();
setState(() {
_errorText = null;
});
final enteredPin = int.tryParse(text);
if (enteredPin == null) {
setState(() {
_errorText = L10n.of(context).invalidInput;
});
_textEditingController.clear();
return;
}
if (AppLock.of(context).unlock(text)) {
setState(() {
_inputBlocked = false;
_errorText = null;
});
_textEditingController.clear();
return;
}
setState(() {
_errorText = L10n.of(context).wrongPinEntered(_coolDownSeconds);
_inputBlocked = true;
});
Future.delayed(Duration(seconds: _coolDownSeconds)).then((_) {
setState(() {
_inputBlocked = false;
_coolDownSeconds *= 2;
_errorText = null;
});
});
_textEditingController.clear();
}
@override
Widget build(BuildContext context) {
return Overlay(
initialEntries: [
OverlayEntry(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).pleaseEnterYourPin),
centerTitle: true,
),
body: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(16.0),
children: [
Center(
child: Image.asset(
'assets/logo/mini/logo_mono_mini.png',
width: 128,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
const SizedBox(height: 16),
Center(
child: TextField(
controller: _textEditingController,
textInputAction: TextInputAction.go,
keyboardType: TextInputType.number,
obscureText: true,
autofocus: true,
textAlign: TextAlign.center,
readOnly: _inputBlocked,
onChanged: (text) {
if (text.length >= 6) tryUnlock(text);
},
onSubmitted: tryUnlock,
style: const TextStyle(fontSize: 40),
inputFormatters: [LengthLimitingTextInputFormatter(6)],
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(40),
),
errorText: _errorText,
hintText: '✱✱✱✱',
hintStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
prefix: AppLock.of(context).useBiometrics
? IconButton(
tooltip: L10n.of(context).unlockWithBiometrics,
icon: FutureBuilder(
future: LocalAuthentication()
.getAvailableBiometrics(),
builder: (context, snapshot) {
final availableBiometrics =
snapshot.data ?? [];
if (availableBiometrics.contains(
BiometricType.face,
)) {
return Icon(Icons.face_unlock_outlined);
}
return Icon(Icons.fingerprint_outlined);
},
),
onPressed: _inputBlocked
? null
: tryUnlockWithBiometrics,
)
: IconButton(
tooltip: L10n.of(context).reset,
icon: Icon(Icons.cancel_outlined),
onPressed: _inputBlocked
? null
: _textEditingController.clear,
),
suffix: IconButton(
tooltip: L10n.of(context).unlock,
icon: Icon(Icons.send_outlined),
onPressed: _inputBlocked
? null
: () => tryUnlock(_textEditingController.text),
),
),
),
),
if (_inputBlocked)
const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator.adaptive(),
),
],
),
),
),
],
);
}
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
}

97
lib/widgets/log_view.dart Normal file
View File

@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class LogViewer extends StatefulWidget {
const LogViewer({super.key});
@override
LogViewerState createState() => LogViewerState();
}
class LogViewerState extends State<LogViewer> {
Level logLevel = Level.debug;
double fontSize = 14;
@override
Widget build(BuildContext context) {
final outputEvents = Logs().outputEvents
.where((e) => e.level.index <= logLevel.index)
.toList();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text(logLevel.toString()),
leading: BackButton(onPressed: () => context.go('/')),
actions: [
IconButton(
icon: const Icon(Icons.zoom_in_outlined),
onPressed: () => setState(() => fontSize++),
),
IconButton(
icon: const Icon(Icons.zoom_out_outlined),
onPressed: () => setState(() => fontSize--),
),
PopupMenuButton<Level>(
useRootNavigator: true,
itemBuilder: (context) => Level.values
.map(
(level) => PopupMenuItem(
value: level,
child: Text(level.toString()),
),
)
.toList(),
onSelected: (Level level) => setState(() => logLevel = level),
),
],
),
body: SelectionArea(
child: ListView.builder(
itemCount: outputEvents.length,
itemBuilder: (context, i) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
outputEvents[i].toDisplayString(),
style: TextStyle(color: outputEvents[i].color),
),
),
),
),
);
}
}
extension on LogEvent {
Color get color {
switch (level) {
case Level.wtf:
return Colors.purple;
case Level.error:
return Colors.red;
case Level.warning:
return Colors.orange;
case Level.info:
return Colors.green;
case Level.debug:
return Colors.white;
case Level.verbose:
return Colors.grey;
}
}
String toDisplayString() {
var str = '# [${level.toString().split('.').last.toUpperCase()}] $title';
if (exception != null) {
str += ' - $exception';
}
if (stackTrace != null) {
str += '\n$stackTrace';
}
return str;
}
}

435
lib/widgets/matrix.dart Normal file
View File

@@ -0,0 +1,435 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/client_manager.dart';
import 'package:tatchat/utils/init_with_restore.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
import 'package:tatchat/utils/notification_background_handler.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/uia_request_manager.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/tatu_chat_app.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:intl/intl.dart';
import 'package:just_audio/just_audio.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_html/html.dart' as html;
import 'package:url_launcher/url_launcher_string.dart';
import '../config/setting_keys.dart';
import '../pages/key_verification/key_verification_dialog.dart';
import '../utils/account_bundles.dart';
import '../utils/background_push.dart';
import 'local_notifications_extension.dart';
class Matrix extends StatefulWidget {
final Widget? child;
final List<Client> clients;
final Map<String, String>? queryParameters;
final SharedPreferences store;
const Matrix({
this.child,
required this.clients,
required this.store,
this.queryParameters,
super.key,
});
@override
MatrixState createState() => MatrixState();
/// Returns the (nearest) Client instance of your application.
static MatrixState of(BuildContext context) =>
Provider.of<MatrixState>(context, listen: false);
}
class MatrixState extends State<Matrix> {
int _activeClient = -1;
String? activeBundle;
SharedPreferences get store => widget.store;
BackgroundPush? backgroundPush;
final ValueNotifier<String?> activeCallRoomId = ValueNotifier(null);
final ValueNotifier<CallPosition> callPosition = ValueNotifier(
CallPosition.fullScreen,
);
Client get client {
if (_activeClient < 0 || _activeClient >= widget.clients.length) {
return currentBundle!.first!;
}
return widget.clients[_activeClient];
}
bool get isMultiAccount => widget.clients.length > 1;
int getClientIndexByMatrixId(String matrixId) =>
widget.clients.indexWhere((client) => client.userID == matrixId);
void setActiveClient(Client? cl) {
final i = widget.clients.indexWhere((c) => c == cl);
if (i != -1) {
_activeClient = i;
} else {
Logs().w('Tried to set an unknown client ${cl!.userID} as active');
}
}
List<Client?>? get currentBundle {
if (!hasComplexBundles) {
return List.from(widget.clients);
}
final bundles = accountBundles;
if (bundles.containsKey(activeBundle)) {
return bundles[activeBundle];
}
return bundles.values.first;
}
Map<String?, List<Client?>> get accountBundles {
final resBundles =
<String?, List<({Client? client, AccountBundle? bundle})>>{};
for (var i = 0; i < widget.clients.length; i++) {
final bundles = widget.clients[i].accountBundles;
for (final bundle in bundles) {
if (bundle.name == null) {
continue;
}
resBundles[bundle.name] ??= [];
resBundles[bundle.name]!.add((
client: widget.clients[i],
bundle: bundle,
));
}
}
for (final b in resBundles.values) {
b.sort(
(a, b) => a.bundle!.priority == null
? 1
: b.bundle!.priority == null
? -1
: a.bundle!.priority!.compareTo(b.bundle!.priority!),
);
}
return resBundles.map(
(k, v) => MapEntry(k, v.map((vv) => vv.client).toList()),
);
}
bool get hasComplexBundles => accountBundles.values.any((v) => v.length > 1);
Client? _loginClientCandidate;
AudioPlayer? audioPlayer;
final ValueNotifier<String?> voiceMessageEventId = ValueNotifier(null);
Future<Client> getLoginClient() async {
if (widget.clients.isNotEmpty && !client.isLogged()) {
return client;
}
final candidate = _loginClientCandidate ??=
await ClientManager.createClient(
'${AppSettings.applicationName.value}-${DateTime.now().millisecondsSinceEpoch}',
store,
)
..onLoginStateChanged.stream
.where((l) => l == LoginState.loggedIn)
.first
.then((_) {
if (!widget.clients.contains(_loginClientCandidate)) {
widget.clients.add(_loginClientCandidate!);
}
ClientManager.addClientNameToStore(
_loginClientCandidate!.clientName,
store,
);
_registerSubs(_loginClientCandidate!.clientName);
setActiveClient(_loginClientCandidate);
_loginClientCandidate = null;
TatuChatApp.router.go('/backup');
});
if (widget.clients.isEmpty) widget.clients.add(candidate);
return candidate;
}
Client? getClientByName(String name) =>
widget.clients.firstWhereOrNull((c) => c.clientName == name);
final onRoomKeyRequestSub = <String, StreamSubscription>{};
final onKeyVerificationRequestSub = <String, StreamSubscription>{};
final onNotification = <String, StreamSubscription>{};
final onLogoutSub = <String, StreamSubscription<LoginState>>{};
final onUiaRequest = <String, StreamSubscription<UiaRequest>>{};
String? _cachedPassword;
Timer? _cachedPasswordClearTimer;
String? get cachedPassword => _cachedPassword;
set cachedPassword(String? p) {
Logs().d('Password cached');
_cachedPasswordClearTimer?.cancel();
_cachedPassword = p;
_cachedPasswordClearTimer = Timer(const Duration(minutes: 10), () {
_cachedPassword = null;
Logs().d('Cached Password cleared');
});
}
String? get activeRoomId {
final route = TatuChatApp.router.routeInformationProvider.value.uri.path;
if (!route.startsWith('/rooms/')) return null;
return route.split('/')[2];
}
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(onStateChange: didChangeAppLifecycleState);
initMatrix();
}
AppLifecycleListener? _listener;
void _registerSubs(String name) {
final c = getClientByName(name);
if (c == null) {
Logs().w(
'Attempted to register subscriptions for non-existing client $name',
);
return;
}
onRoomKeyRequestSub[name] ??= c.onRoomKeyRequest.stream.listen((
RoomKeyRequest request,
) async {
if (widget.clients.any(
((cl) =>
cl.userID == request.requestingDevice.userId &&
cl.identityKey == request.requestingDevice.curve25519Key),
)) {
Logs().i(
'[Key Request] Request is from one of our own clients, forwarding the key...',
);
await request.forwardKey();
}
});
onKeyVerificationRequestSub[name] ??= c.onKeyVerificationRequest.stream
.listen((KeyVerification request) async {
var hidPopup = false;
request.onUpdate = () {
if (!hidPopup &&
{
KeyVerificationState.done,
KeyVerificationState.error,
}.contains(request.state)) {
TatuChatApp.router.pop('dialog');
}
hidPopup = true;
};
request.onUpdate = null;
hidPopup = true;
await KeyVerificationDialog(request: request).show(
TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
context,
);
});
onLogoutSub[name] ??= c.onLoginStateChanged.stream
.where((state) => state == LoginState.loggedOut)
.listen((_) {
final loggedInWithMultipleClients = widget.clients.length > 1;
_cancelSubs(c.clientName);
widget.clients.remove(c);
ClientManager.removeClientNameFromStore(c.clientName, store);
InitWithRestoreExtension.deleteSessionBackup(name);
if (loggedInWithMultipleClients) {
final snackbarContext =
TatuChatApp
.router
.routerDelegate
.navigatorKey
.currentContext ??
context;
if (!snackbarContext.mounted) return;
final l10n = L10n.of(snackbarContext);
ScaffoldMessenger.of(
snackbarContext,
).showSnackBar(SnackBar(content: Text(l10n.oneClientLoggedOut)));
return;
}
TatuChatApp.router.go('/');
});
onUiaRequest[name] ??= c.onUiaRequest.stream.listen(uiaRequestHandler);
if (PlatformInfos.isWeb || PlatformInfos.isLinux) {
FlutterLocalNotificationsPlugin().initialize(
settings: InitializationSettings(
linux: LinuxInitializationSettings(
defaultActionName: FluffyChatNotificationActions.open.name,
),
),
onDidReceiveNotificationResponse: (response) => notificationTap(
response,
clients: widget.clients,
router: TatuChatApp.router,
l10n: null,
),
);
c.onSync.stream.first.then((s) {
html.Notification.requestPermission();
onNotification[name] ??= c.onNotification.stream.listen(
showLocalNotification,
);
});
}
}
void _cancelSubs(String name) {
onRoomKeyRequestSub[name]?.cancel();
onRoomKeyRequestSub.remove(name);
onKeyVerificationRequestSub[name]?.cancel();
onKeyVerificationRequestSub.remove(name);
onLogoutSub[name]?.cancel();
onLogoutSub.remove(name);
onNotification[name]?.cancel();
onNotification.remove(name);
}
void initMatrix() {
for (final c in widget.clients) {
_registerSubs(c.clientName);
}
if (PlatformInfos.isMobile) {
backgroundPush = BackgroundPush(
this,
onFcmError: (errorMsg, {Uri? link}) async {
final context =
TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
this.context;
if (!context.mounted) return;
final result = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).pushNotificationsNotAvailable,
message: errorMsg,
okLabel: link == null
? L10n.of(context).ok
: L10n.of(context).learnMore,
cancelLabel: L10n.of(context).doNotShowAgain,
);
if (result == OkCancelResult.ok && link != null) {
launchUrlString(
link.toString(),
mode: LaunchMode.externalApplication,
);
}
if (result == OkCancelResult.cancel) {
await AppSettings.showNoGoogle.setItem(true);
}
},
);
}
}
void didChangeAppLifecycleState(AppLifecycleState state) {
final foreground =
state != AppLifecycleState.inactive &&
state != AppLifecycleState.paused;
for (final client in widget.clients) {
client.syncPresence = state == AppLifecycleState.resumed
? null
: PresenceType.unavailable;
if (PlatformInfos.isMobile) {
client.backgroundSync = foreground;
client.requestHistoryOnLimitedTimeline = !foreground;
Logs().v('Set background sync to', foreground);
}
}
}
@override
void dispose() {
_listener?.dispose();
for (final sub in onRoomKeyRequestSub.values) {
sub.cancel();
}
for (final sub in onKeyVerificationRequestSub.values) {
sub.cancel();
}
for (final sub in onLogoutSub.values) {
sub.cancel();
}
for (final sub in onNotification.values) {
sub.cancel();
}
for (final sub in onUiaRequest.values) {
sub.cancel();
}
onRoomKeyRequestSub.clear();
onKeyVerificationRequestSub.clear();
onLogoutSub.clear();
onNotification.clear();
onUiaRequest.clear();
voiceMessageEventId.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Provider(create: (_) => this, child: widget.child);
}
Future<void> dehydrateAction(BuildContext context) async {
final l10n = L10n.of(context);
final response = await showOkCancelAlertDialog(
context: context,
isDestructive: true,
title: l10n.dehydrate,
message: l10n.dehydrateWarning,
);
if (response != OkCancelResult.ok) {
return;
}
if (!context.mounted) return;
final result = await showFutureLoadingDialog(
context: context,
future: client.exportDump,
);
final export = result.result;
if (export == null) return;
final exportBytes = Uint8List.fromList(const Utf8Codec().encode(export));
final exportFileName =
'fluffychat-export-${DateFormat(DateFormat.YEAR_MONTH_DAY).format(DateTime.now())}.fluffybackup';
final file = MatrixFile(bytes: exportBytes, name: exportFileName);
if (!context.mounted) return;
file.save(context);
}
}
enum CallPosition { fullScreen, top }

View File

@@ -0,0 +1,377 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/permission_slider_dialog.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'adaptive_dialogs/show_text_input_dialog.dart';
import 'adaptive_dialogs/user_dialog.dart';
import 'avatar.dart';
import 'future_loading_dialog.dart';
Future<void> showMemberActionsPopupMenu({
required BuildContext context,
required User user,
void Function()? onMention,
}) async {
final l10n = L10n.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final theme = Theme.of(context);
final displayname = user.calcDisplayname();
final isMe = user.room.client.userID == user.id;
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final button = context.findRenderObject() as RenderBox;
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,
);
const defaultPowerLevels = {0, 50, 100, 9007199254740991};
final action = await showMenu<_MemberActions>(
context: context,
position: position,
items: <PopupMenuEntry<_MemberActions>>[
PopupMenuItem(
value: _MemberActions.info,
child: Row(
spacing: 12.0,
children: [
Avatar(name: displayname, size: 30, mxContent: user.avatarUrl),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (onMention != null)
PopupMenuItem(
value: _MemberActions.mention,
child: Row(
children: [
const Icon(Icons.alternate_email_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).mention),
],
),
),
if (user.membership == Membership.knock)
PopupMenuItem(
value: _MemberActions.approve,
child: Row(
children: [
const Icon(Icons.how_to_reg_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).approve),
],
),
),
if (user.canChangeUserPowerLevel) ...[
if (user.powerLevel.level < 100)
PopupMenuItem(
value: _MemberActions.makeAdmin,
child: Row(
children: [
const Icon(Icons.admin_panel_settings_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).makeAdmin),
],
),
),
if (user.powerLevel.level < 50)
PopupMenuItem(
value: _MemberActions.makeModerator,
child: Row(
children: [
const Icon(Icons.add_moderator_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).makeModerator),
],
),
),
if (user.powerLevel.role == PowerLevelRole.admin)
PopupMenuItem(
value: _MemberActions.removeAdmin,
child: Row(
children: [
const Icon(Icons.remove_moderator_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).removeAdminRights),
],
),
)
else if (user.powerLevel.role == PowerLevelRole.moderator)
PopupMenuItem(
value: _MemberActions.removeModerator,
child: Row(
children: [
const Icon(Icons.remove_moderator_outlined),
const SizedBox(width: 18),
Text(L10n.of(context).removeModeratorRights),
],
),
),
],
if (user.canChangeUserPowerLevel ||
!defaultPowerLevels.contains(user.powerLevel.level))
PopupMenuItem(
value: _MemberActions.setPowerLevel,
enabled: user.canChangeUserPowerLevel,
child: Row(
children: [
const Icon(Icons.manage_accounts_outlined),
const SizedBox(width: 18),
Text(
user.canChangeUserPowerLevel
? L10n.of(context).setPowerLevel
: L10n.of(context).powerLevel,
),
if (!defaultPowerLevels.contains(user.powerLevel.level))
Text(' (${user.powerLevel})'),
],
),
),
if (user.canKick)
PopupMenuItem(
value: _MemberActions.kick,
child: Row(
children: [
Icon(
Icons.person_remove_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 18),
Text(
L10n.of(context).kickFromChat,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
],
),
),
if (user.canBan && user.membership != Membership.ban)
PopupMenuItem(
value: _MemberActions.ban,
child: Row(
children: [
Icon(
Icons.block_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 18),
Text(
L10n.of(context).banFromChat,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
],
),
),
if (user.canBan && user.membership == Membership.ban)
PopupMenuItem(
value: _MemberActions.unban,
child: Row(
children: [
const Icon(Icons.warning),
const SizedBox(width: 18),
Text(L10n.of(context).unbanFromChat),
],
),
),
if (!isMe)
PopupMenuItem(
value: _MemberActions.report,
child: Row(
children: [
Icon(
Icons.gavel_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 18),
Text(
L10n.of(context).reportUser,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
],
),
),
],
);
if (action == null) return;
if (!context.mounted) return;
switch (action) {
case _MemberActions.mention:
onMention?.call();
return;
case _MemberActions.setPowerLevel:
final power = await showPermissionChooser(
context,
currentLevel: user.powerLevel.level,
maxLevel: user.room.ownPowerLevel.level,
);
if (power == null) return;
if (!context.mounted) return;
if (power >= 100) {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
message: L10n.of(context).makeAdminDescription,
);
if (consent != OkCancelResult.ok) return;
if (!context.mounted) return;
}
await showFutureLoadingDialog(
context: context,
future: () => user.setPower(power),
);
return;
case _MemberActions.approve:
await showFutureLoadingDialog(
context: context,
future: () => user.room.invite(user.id),
);
return;
case _MemberActions.kick:
if (await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
okLabel: l10n.yes,
cancelLabel: l10n.no,
message: l10n.kickUserDescription,
) ==
OkCancelResult.ok) {
if (!context.mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => user.kick(),
);
}
return;
case _MemberActions.ban:
if (await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
okLabel: l10n.yes,
cancelLabel: l10n.no,
message: l10n.banUserDescription,
) ==
OkCancelResult.ok) {
if (!context.mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => user.ban(),
);
}
return;
case _MemberActions.report:
final reason = await showTextInputDialog(
context: context,
title: l10n.whyDoYouWantToReportThis,
okLabel: l10n.report,
cancelLabel: l10n.cancel,
hintText: l10n.reason,
);
if (reason == null || reason.isEmpty) return;
if (!context.mounted) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => user.room.client.reportUser(user.id, reason),
);
if (result.error != null) return;
if (!context.mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(content: Text(l10n.contentHasBeenReported)),
);
return;
case _MemberActions.info:
await UserDialog.show(
context: context,
profile: Profile(
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
),
);
return;
case _MemberActions.unban:
if (await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
okLabel: l10n.yes,
cancelLabel: l10n.no,
message: l10n.unbanUserDescription,
) ==
OkCancelResult.ok) {
if (!context.mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => user.unban(),
);
}
case _MemberActions.makeAdmin:
if (user.room.ownPowerLevel.level <= 100) {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
message: L10n.of(context).makeAdminDescription,
);
if (consent != OkCancelResult.ok) return;
if (!context.mounted) return;
}
await showFutureLoadingDialog(
context: context,
future: () => user.setPower(100),
);
case _MemberActions.makeModerator:
await showFutureLoadingDialog(
context: context,
future: () => user.setPower(50),
);
case _MemberActions.removeAdmin:
case _MemberActions.removeModerator:
final defaultUserLevel =
user.room
.getState(EventTypes.RoomPowerLevels)
?.content
.tryGet<int>('users_default') ??
0;
await showFutureLoadingDialog(
context: context,
future: () => user.setPower(defaultUserLevel),
);
}
}
enum _MemberActions {
info,
mention,
setPowerLevel,
makeAdmin,
makeModerator,
removeAdmin,
removeModerator,
kick,
ban,
approve,
unban,
report,
}

266
lib/widgets/mxc_image.dart Normal file
View File

@@ -0,0 +1,266 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/utils/client_download_content_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:lottie/lottie.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class MxcImage extends StatefulWidget {
final Uri? uri;
final Event? event;
final double? width;
final double? height;
final BoxFit? fit;
final bool isThumbnail;
final bool animated;
final Duration retryDuration;
final Duration animationDuration;
final Curve animationCurve;
final ThumbnailMethod thumbnailMethod;
final Widget Function(BuildContext context)? placeholder;
final String? cacheKey;
final String? cacheName;
final Client? client;
final BorderRadius borderRadius;
static void clearCache(String cacheName) =>
_MxcImageState._imageDataCaches.remove(cacheName);
const MxcImage({
this.uri,
this.event,
this.width,
this.height,
this.fit,
this.placeholder,
this.isThumbnail = true,
this.animated = false,
this.animationDuration = FluffyThemes.animationDuration,
this.retryDuration = const Duration(seconds: 2),
this.animationCurve = FluffyThemes.animationCurve,
this.thumbnailMethod = ThumbnailMethod.scale,
this.cacheKey,
this.client,
this.borderRadius = BorderRadius.zero,
this.cacheName,
super.key,
});
@override
State<MxcImage> createState() => _MxcImageState();
}
class _MxcImageState extends State<MxcImage> {
static final Map<String?, Map<String, Uint8List>> _imageDataCaches = {};
Map<String, Uint8List> get _imageDataCache =>
_imageDataCaches[widget.cacheName ?? ''] ??= {};
Uint8List? _imageDataNoCache;
Uint8List? get _imageData => widget.cacheKey == null
? _imageDataNoCache
: _imageDataCache[widget.cacheKey];
set _imageData(Uint8List? data) {
if (data == null) return;
final cacheKey = widget.cacheKey;
cacheKey == null
? _imageDataNoCache = data
: _imageDataCache[cacheKey] = data;
}
Future<void> _load() async {
if (!mounted) return;
final client =
widget.client ?? widget.event?.room.client ?? Matrix.of(context).client;
final uri = widget.uri;
final event = widget.event;
if (uri != null) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final width = widget.width;
final realWidth = width == null ? null : width * devicePixelRatio;
final height = widget.height;
final realHeight = height == null ? null : height * devicePixelRatio;
final remoteData = await client.downloadMxcCached(
uri,
width: realWidth,
height: realHeight,
thumbnailMethod: widget.thumbnailMethod,
isThumbnail: widget.isThumbnail,
animated: widget.animated,
);
if (!mounted) return;
setState(() {
_imageData = remoteData;
});
}
if (event != null) {
final useThumbnail = widget.isThumbnail && event.hasThumbnail;
if (!useThumbnail &&
!{
MessageTypes.Image,
MessageTypes.Sticker,
}.contains(event.messageType)) {
Logs().e('Event of type ${event.messageType} has no thumbnail!');
}
final data = await event.downloadAndDecryptAttachment(
getThumbnail: useThumbnail,
);
if (data.detectFileType is MatrixImageFile) {
if (!mounted) return;
setState(() {
_imageData = data.bytes;
});
return;
}
}
}
Future<void> _tryLoad() async {
if (_imageData != null) {
return;
}
try {
await _load();
} on IOException catch (_) {
if (!mounted) return;
await Future.delayed(widget.retryDuration);
_tryLoad();
} on MatrixException catch (e) {
Logs().d('Unable to load image', e);
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _tryLoad());
}
@override
Widget build(BuildContext context) {
final data = _imageData;
final hasData = data != null && data.isNotEmpty;
final ungzippedLottieData = data == null ? null : _ungzipLottie(data);
Widget errorFallback(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
Logs().d('Unable to render mxc image', error, stackTrace);
return SizedBox(
width: widget.width,
height: widget.height,
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Icon(
Icons.broken_image_outlined,
size: min(widget.height ?? 64, 64),
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
final imageChild = data == null
? _MxcImagePlaceholder(
width: widget.width,
height: widget.height,
placeholder: widget.placeholder,
)
: (ungzippedLottieData != null
? Lottie.memory(
ungzippedLottieData,
width: widget.width,
height: widget.height,
fit: widget.fit,
errorBuilder: errorFallback,
)
: Image.memory(
data,
width: widget.width,
height: widget.height,
fit: widget.fit,
filterQuality: widget.isThumbnail
? FilterQuality.low
: FilterQuality.medium,
errorBuilder: errorFallback,
));
return AnimatedCrossFade(
duration: FluffyThemes.animationDuration,
firstChild: ClipRRect(
borderRadius: widget.borderRadius,
child: imageChild,
),
secondChild: _MxcImagePlaceholder(
width: widget.width,
height: widget.height,
placeholder: widget.placeholder,
),
crossFadeState: hasData
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
);
}
}
class _MxcImagePlaceholder extends StatelessWidget {
final double? width;
final double? height;
final Widget Function(BuildContext context)? placeholder;
const _MxcImagePlaceholder({
required this.width,
required this.height,
required this.placeholder,
});
@override
Widget build(BuildContext context) {
return placeholder?.call(context) ??
Container(
width: width,
height: height,
alignment: Alignment.center,
child: const CircularProgressIndicator.adaptive(strokeWidth: 2),
);
}
}
Uint8List? _ungzipLottie(Uint8List data) {
// early return if the data is not gzipped
if (data.length < 2 || data.first != 0x1f || data[1] != 0x8b) {
return null;
}
// try decoding json
try {
final decompressed = GZipDecoder().decodeBytes(data);
final jsonStr = utf8.decode(decompressed);
final json = jsonDecode(jsonStr);
if (json is Map && json.containsKey('v')) {
return Uint8List.fromList(utf8.encode(jsonStr));
}
} catch (_) {
return null;
}
return null;
}

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:material_ui/material_ui.dart';
import 'mxc_image.dart';
class MxcImageViewer extends StatelessWidget {
final Uri mxContent;
const MxcImageViewer(this.mxContent, {super.key});
@override
Widget build(BuildContext context) {
final iconButtonStyle = IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(200),
foregroundColor: Colors.white,
);
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Scaffold(
backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
style: iconButtonStyle,
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Colors.transparent,
),
body: InteractiveViewer(
minScale: 1.0,
maxScale: 10.0,
onInteractionEnd: (endDetails) {
if (endDetails.velocity.pixelsPerSecond.dy >
MediaQuery.sizeOf(context).height * 1.5) {
Navigator.of(context, rootNavigator: false).pop();
}
},
child: Center(
child: GestureDetector(
// Ignore taps to not go back here:
onTap: () {},
child: MxcImage(
key: ValueKey(mxContent.toString()),
uri: mxContent,
fit: BoxFit.contain,
isThumbnail: false,
animated: true,
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
import 'package:material_ui/material_ui.dart';
Future<int?> showPermissionChooser(
BuildContext context, {
int currentLevel = 0,
int maxLevel = 100,
}) async {
final controller = TextEditingController(text: currentLevel.toString());
final error = ValueNotifier<String?>(null);
return await showAdaptiveDialog<int>(
context: context,
builder: (context) => AlertDialog.adaptive(
title: Center(child: Text(L10n.of(context).chatPermissions)),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
spacing: 16.0,
children: [
Text(L10n.of(context).setPowerLevelDescription),
ValueListenableBuilder(
valueListenable: error,
builder: (context, errorText, _) => DialogTextField(
controller: controller,
hintText: currentLevel.toString(),
keyboardType: TextInputType.number,
labelText: L10n.of(context).custom,
errorText: errorText,
),
),
],
),
),
actions: [
AdaptiveDialogAction(
onPressed: () {
final level = int.tryParse(controller.text.trim());
if (level == null) {
error.value = L10n.of(context).pleaseEnterANumber;
return;
}
if (level > maxLevel) {
error.value = L10n.of(context).noPermission;
return;
}
Navigator.of(context).pop<int>(level);
},
child: Text(L10n.of(context).setPowerLevel),
),
AdaptiveDialogAction(
onPressed: () => Navigator.of(context).pop<int>(null),
child: Text(L10n.of(context).cancel),
),
],
),
);
}

View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:tatchat/widgets/matrix.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class PresenceBuilder extends StatefulWidget {
final Widget Function(BuildContext context, CachedPresence? presence) builder;
final String? userId;
final Client? client;
const PresenceBuilder({
required this.builder,
this.userId,
this.client,
super.key,
});
@override
State<PresenceBuilder> createState() => _PresenceBuilderState();
}
class _PresenceBuilderState extends State<PresenceBuilder> {
CachedPresence? _presence;
StreamSubscription<CachedPresence>? _sub;
void _updatePresence(CachedPresence? presence) {
if (!mounted) return;
setState(() {
_presence = presence;
});
}
@override
void initState() {
super.initState();
final client = widget.client ?? Matrix.of(context).client;
final userId = widget.userId;
if (userId != null) {
client.fetchCurrentPresence(userId).then(_updatePresence);
_sub = client.onPresenceChanged.stream
.where((presence) => presence.userid == userId)
.listen(_updatePresence);
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.builder(context, _presence);
}

View File

@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:flutter/material.dart';
class PulsatingWidget extends StatefulWidget {
final Color color;
final Widget child;
final double spreadRadius;
const PulsatingWidget({
required this.child,
this.color = Colors.red,
this.spreadRadius = 1.0,
super.key,
});
@override
State<PulsatingWidget> createState() => _PulsatingWidgetState();
}
/// [AnimationController]s can be created with `vsync: this` because of
/// [TickerProviderStateMixin].
class _PulsatingWidgetState extends State<PulsatingWidget>
with TickerProviderStateMixin {
late final DecorationTween decorationTween = DecorationTween(
begin: BoxDecoration(
borderRadius: BorderRadius.circular(128),
boxShadow: <BoxShadow>[
BoxShadow(
color: widget.color.withAlpha(16),
blurRadius: 0.0,
spreadRadius: 0.0,
),
],
),
end: BoxDecoration(
borderRadius: BorderRadius.circular(128),
boxShadow: <BoxShadow>[
BoxShadow(
color: widget.color.withAlpha(128),
blurRadius: 0.0,
spreadRadius: widget.spreadRadius,
),
],
),
);
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) return widget.child;
return DecoratedBoxTransition(
decoration: decorationTween.animate(_controller),
child: widget.child,
);
}
}

View File

@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:particles_network/particles_network.dart';
import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:qr_image/qr_image.dart';
import '../config/themes.dart';
Future<void> showQrCodeViewer(BuildContext context, String content) =>
showDialog(
context: context,
builder: (context) => QrCodeViewer(content: content),
);
class QrCodeViewer extends StatelessWidget {
final String content;
const QrCodeViewer({required this.content, super.key});
Future<void> _save(BuildContext context) async {
final imageResult = await showFutureLoadingDialog(
context: context,
future: () {
final inviteLink = 'https://matrix.to/#/$content';
final image = QRImage(inviteLink, size: 256, radius: 1).generate();
return compute(encodePng, image);
},
);
final bytes = imageResult.result;
if (bytes == null) return;
if (!context.mounted) return;
MatrixImageFile(
bytes: bytes,
name: 'QR_Code_$content.png',
mimeType: 'image/png',
).save(context);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final inviteLink = 'https://matrix.to/#/$content';
return Scaffold(
backgroundColor: Colors.black.withAlpha(128),
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
leading: IconButton(
style: IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(128),
),
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
color: Colors.white,
tooltip: L10n.of(context).close,
),
backgroundColor: Colors.transparent,
actions: [
IconButton(
style: IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(128),
),
icon: Icon(Icons.adaptive.share_outlined),
onPressed: () => TatuShare.share(inviteLink, context),
color: Colors.white,
tooltip: L10n.of(context).share,
),
const SizedBox(width: 8),
IconButton(
style: IconButton.styleFrom(
backgroundColor: Colors.black.withAlpha(128),
),
icon: const Icon(Icons.download_outlined),
onPressed: () => _save(context),
color: Colors.white,
tooltip: L10n.of(context).downloadFile,
),
const SizedBox(width: 8),
],
),
body: Stack(
children: [
if (!MediaQuery.disableAnimationsOf(context))
ParticleNetwork(maxSpeed: 0.25),
Center(
child: Container(
margin: const EdgeInsets.all(32.0),
padding: const EdgeInsets.all(32.0),
decoration: BoxDecoration(
border: Border.all(
color: theme.colorScheme.onSurface,
width: 8,
),
color: theme.colorScheme.surfaceBright,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
child: Column(
mainAxisSize: .min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth,
),
child: PrettyQrView.data(
data: inviteLink,
decoration: PrettyQrDecoration(
shape: PrettyQrSmoothSymbol(
roundFactor: 1,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(height: 8.0),
SelectableText(
content,
textAlign: TextAlign.center,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: 12,
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/setting_keys.dart';
import 'package:material_ui/material_ui.dart';
class SettingsSwitchListTile extends StatefulWidget {
final AppSettings<bool?> setting;
final String title;
final String? subtitle;
final Function(bool)? onChanged;
final bool? defaultValue;
const SettingsSwitchListTile.adaptive({
super.key,
required this.setting,
required this.title,
this.subtitle,
this.onChanged,
this.defaultValue,
});
@override
SettingsSwitchListTileState createState() => SettingsSwitchListTileState();
}
class SettingsSwitchListTileState extends State<SettingsSwitchListTile> {
@override
Widget build(BuildContext context) {
final subtitle = widget.subtitle;
return SwitchListTile.adaptive(
value: widget.setting.value ?? widget.defaultValue ?? false,
title: Text(widget.title),
subtitle: subtitle == null ? null : Text(subtitle),
onChanged: (bool newValue) async {
widget.onChanged?.call(newValue);
await widget.setting.setItem(newValue);
setState(() {});
},
);
}
}

View File

@@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cross_file/cross_file.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
abstract class ShareItem {}
class TextShareItem extends ShareItem {
final String value;
TextShareItem(this.value);
}
class ContentShareItem extends ShareItem {
final Map<String, Object?> value;
ContentShareItem(this.value);
}
class FileShareItem extends ShareItem {
final XFile value;
FileShareItem(this.value);
}
class ShareScaffoldDialog extends StatefulWidget {
final List<ShareItem> items;
const ShareScaffoldDialog({required this.items, super.key});
@override
State<ShareScaffoldDialog> createState() => _ShareScaffoldDialogState();
}
class _ShareScaffoldDialogState extends State<ShareScaffoldDialog> {
final TextEditingController _filterController = TextEditingController();
String? selectedRoomId;
void _toggleRoom(String roomId) {
setState(() {
selectedRoomId = roomId;
});
}
Future<void> _forwardAction() async {
final roomId = selectedRoomId;
if (roomId == null) {
throw Exception(
'Started forward action before room was selected. This should never happen.',
);
}
if (widget.items.any((item) => item is! FileShareItem)) {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).forwardCountMessages(widget.items.length),
okLabel: L10n.of(context).forward,
cancelLabel: L10n.of(context).cancel,
);
if (consent != OkCancelResult.ok) return;
}
if (!mounted) return;
while (context.canPop()) {
context.pop();
}
context.go('/rooms/$roomId', extra: widget.items);
}
@override
void dispose() {
_filterController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final rooms = Matrix.of(context).client.rooms
.where(
(room) =>
room.canSendDefaultMessages &&
!room.isSpace &&
room.membership == Membership.join,
)
.toList();
final filter = _filterController.text.trim().toLowerCase();
return Scaffold(
appBar: AppBar(
leading: Center(child: CloseButton(onPressed: context.pop)),
title: Text(L10n.of(context).share),
),
body: 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(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context).search,
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: IconButton(
onPressed: () {},
icon: Icon(
Icons.search_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
),
),
SliverList.builder(
itemCount: rooms.length,
itemBuilder: (context, i) {
final room = rooms[i];
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
final value = selectedRoomId == room.id;
final filterOut = !displayname.toLowerCase().contains(filter);
if (!value && filterOut) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Opacity(
opacity: filterOut ? 0.5 : 1,
child: FutureBuilder(
future: room.loadHeroUsers(),
builder: (context, _) => CheckboxListTile.adaptive(
checkboxShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(90),
),
controlAffinity: ListTileControlAffinity.trailing,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
secondary: Avatar(
mxContent: room.avatar,
name: displayname,
size: Avatar.defaultSize * 0.75,
),
title: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
room.directChatMatrixID ??
L10n.of(context).countParticipants(
(room.summary.mJoinedMemberCount ?? 0) +
(room.summary.mInvitedMemberCount ?? 0),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
value: selectedRoomId == room.id,
onChanged: (_) => _toggleRoom(room.id),
),
),
),
);
},
),
],
),
bottomNavigationBar: AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: selectedRoomId == null
? const SizedBox.shrink()
: Material(
elevation: 8,
shadowColor: theme.appBarTheme.shadowColor,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: _forwardAction,
child: Text(L10n.of(context).forward),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/routes.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/widgets/app_lock.dart';
import 'package:tatchat/widgets/layouts/call_overlay.dart';
import 'package:tatchat/widgets/theme_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/custom_scroll_behaviour.dart';
import 'matrix.dart';
class TatuChatApp extends StatelessWidget {
final Widget? testWidget;
final List<Client> clients;
final ({String? pincode, bool useBiometrics}) appLockSettings;
final SharedPreferences store;
const TatuChatApp({
super.key,
this.testWidget,
required this.clients,
required this.store,
required this.appLockSettings,
});
/// getInitialLink may rereturn the value multiple times if this view is
/// opened multiple times for example if the user logs out after they logged
/// in with qr code or magic link.
static bool gotInitialLink = false;
// Router must be outside of build method so that hot reload does not reset
// the current path.
static final GoRouter router = GoRouter(
routes: AppRoutes.routes,
debugLogDiagnostics: true,
redirect: (context, state) {
// Workaround for content sharings passed to go router:
if ({
'content',
'sharemedia-im.tatuchat.app',
}.contains(state.uri.scheme)) {
Logs().d('Ignore content sharing handling in go router', state.uri);
return '/';
}
// Pass deep links to app:
if (state.uri.toString().startsWith(AppConfig.deepLinkPrefix)) {
return '/rooms/newprivatechat#${state.uri}';
}
return null;
},
);
@override
Widget build(BuildContext context) {
return ThemeBuilder(
builder: (context, themeMode, primaryColor) => MaterialApp.router(
title: AppSettings.applicationName.value,
themeMode: themeMode,
theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor),
darkTheme: FluffyThemes.buildTheme(
context,
Brightness.dark,
primaryColor,
),
scrollBehavior: CustomScrollBehavior(),
localizationsDelegates: [
...L10n.localizationsDelegates,
...GlobalMaterialLocalizations.delegates,
...GlobalCupertinoLocalizations.delegates,
],
supportedLocales: L10n.supportedLocales,
routerConfig: router,
builder: (context, child) => AppLockWidget(
pincode: appLockSettings.pincode,
useBiometrics: appLockSettings.useBiometrics,
isLoggedIn: clients.any((client) => client.isLogged()),
// Need a navigator above the Matrix widget for
// displaying dialogs
child: Matrix(
clients: clients,
store: store,
child: CallOverlay(
child: testWidget ?? child ?? const SizedBox.shrink(),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:collection/collection.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:tatchat/utils/color_value.dart';
import 'package:material_ui/material_ui.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeBuilder extends StatefulWidget {
final Widget Function(
BuildContext context,
ThemeMode themeMode,
Color? primaryColor,
)
builder;
final String themeModeSettingsKey;
final String primaryColorSettingsKey;
const ThemeBuilder({
required this.builder,
this.themeModeSettingsKey = 'theme_mode',
this.primaryColorSettingsKey = 'primary_color',
super.key,
});
@override
State<ThemeBuilder> createState() => ThemeController();
}
class ThemeController extends State<ThemeBuilder> {
SharedPreferences? _sharedPreferences;
ThemeMode? _themeMode;
Color? _primaryColor;
ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
Color? get primaryColor => _primaryColor;
static ThemeController of(BuildContext context) =>
Provider.of<ThemeController>(context, listen: false);
Future<void> _loadData(_) async {
final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance();
final rawThemeMode = preferences.getString(widget.themeModeSettingsKey);
final rawColor = preferences.getInt(widget.primaryColorSettingsKey);
setState(() {
_themeMode = ThemeMode.values.singleWhereOrNull(
(value) => value.name == rawThemeMode,
);
_primaryColor = rawColor == null ? null : Color(rawColor);
});
}
Future<void> setThemeMode(ThemeMode newThemeMode) async {
final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance();
await preferences.setString(widget.themeModeSettingsKey, newThemeMode.name);
setState(() {
_themeMode = newThemeMode;
});
}
Future<void> setPrimaryColor(Color? newPrimaryColor) async {
final preferences = _sharedPreferences ??=
await SharedPreferences.getInstance();
if (newPrimaryColor == null) {
await preferences.remove(widget.primaryColorSettingsKey);
} else {
await preferences.setInt(
widget.primaryColorSettingsKey,
newPrimaryColor.hexValue,
);
}
setState(() {
_primaryColor = newPrimaryColor;
});
}
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback(_loadData);
super.initState();
}
@override
Widget build(BuildContext context) {
return Provider(
create: (_) => this,
child: DynamicColorBuilder(
builder: (light, _) =>
widget.builder(context, themeMode, primaryColor ?? light?.primary),
),
);
}
}

View File

@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:tatchat/config/themes.dart';
import 'package:material_ui/material_ui.dart';
class TypingAnimation extends StatefulWidget {
final double size;
const TypingAnimation({this.size = 8.0, super.key});
@override
State<TypingAnimation> createState() => _TypingAnimationState();
}
class _TypingAnimationState extends State<TypingAnimation> {
int _tick = 0;
late final Timer _timer;
static const Duration animationDuration = Duration(milliseconds: 300);
@override
void initState() {
_timer = Timer.periodic(animationDuration, (_) {
if (!mounted) {
return;
}
setState(() {
_tick = (_tick + 1) % 4;
});
});
super.initState();
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = widget.size;
return Row(
mainAxisSize: .min,
children: [
for (var i = 1; i <= 3; i++)
AnimatedContainer(
duration: animationDuration * 1.5,
curve: FluffyThemes.animationCurve,
width: size,
height: _tick == i ? size * 2 : size,
margin: EdgeInsets.symmetric(
horizontal: size / 4,
vertical: _tick == i ? 4 : 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 2),
color: theme.colorScheme.secondary,
),
),
],
);
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:badges/badges.dart' as b;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'matrix.dart';
class UnreadRoomsBadge extends StatelessWidget {
final bool Function(Room) filter;
final b.BadgePosition? badgePosition;
final Widget? child;
const UnreadRoomsBadge({
super.key,
required this.filter,
this.badgePosition,
this.child,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(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.colorScheme.tertiary,
elevation: 4,
borderSide: BorderSide(color: theme.colorScheme.surface, width: 2),
),
badgeContent: Text(
unreadCount.toString(),
style: TextStyle(color: theme.colorScheme.onTertiary, fontSize: 12),
),
showBadge: unreadCount != 0,
badgeAnimation: const b.BadgeAnimation.scale(),
position: badgePosition ?? b.BadgePosition.bottomEnd(),
child: child,
);
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:material_ui/material_ui.dart';
class ViewModelBuilder<T extends ValueNotifier> extends StatefulWidget {
final T Function() create;
final Widget Function(BuildContext context, T viewModel, Widget? child)
builder;
final Widget? child;
const ViewModelBuilder({
super.key,
required this.create,
required this.builder,
this.child,
});
@override
State<ViewModelBuilder<T>> createState() => _ViewModelBuilderState<T>();
}
class _ViewModelBuilderState<T extends ValueNotifier>
extends State<ViewModelBuilder<T>> {
late final T _viewModel;
@override
void initState() {
_viewModel = widget.create();
super.initState();
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _viewModel,
builder: (context, value, child) =>
widget.builder.call(context, _viewModel, child),
);
}
}