Initial commit
This commit is contained in:
164
lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart
Normal file
164
lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/widgets/adaptive_dialogs/dialog_text_field.dart
Normal file
120
lib/widgets/adaptive_dialogs/dialog_text_field.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
308
lib/widgets/adaptive_dialogs/public_room_dialog.dart
Normal file
308
lib/widgets/adaptive_dialogs/public_room_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
lib/widgets/adaptive_dialogs/show_modal_action_popup.dart
Normal file
119
lib/widgets/adaptive_dialogs/show_modal_action_popup.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
106
lib/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart
Normal file
106
lib/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
112
lib/widgets/adaptive_dialogs/show_text_input_dialog.dart
Normal file
112
lib/widgets/adaptive_dialogs/show_text_input_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
274
lib/widgets/adaptive_dialogs/user_dialog.dart
Normal file
274
lib/widgets/adaptive_dialogs/user_dialog.dart
Normal 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
146
lib/widgets/app_lock.dart
Normal 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
170
lib/widgets/avatar.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/widgets/blur_hash.dart
Normal file
91
lib/widgets/blur_hash.dart
Normal 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};
|
||||
}
|
||||
145
lib/widgets/chat_settings_popup_menu.dart
Normal file
145
lib/widgets/chat_settings_popup_menu.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
146
lib/widgets/config_viewer.dart
Normal file
146
lib/widgets/config_viewer.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/widgets/future_loading_dialog.dart
Normal file
170
lib/widgets/future_loading_dialog.dart
Normal 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;
|
||||
}
|
||||
34
lib/widgets/hover_builder.dart
Normal file
34
lib/widgets/hover_builder.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/widgets/incoming_call_dialog.dart
Normal file
117
lib/widgets/incoming_call_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/widgets/layouts/call_overlay.dart
Normal file
70
lib/widgets/layouts/call_overlay.dart
Normal 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))],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/widgets/layouts/empty_page.dart
Normal file
37
lib/widgets/layouts/empty_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
144
lib/widgets/layouts/login_scaffold.dart
Normal file
144
lib/widgets/layouts/login_scaffold.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/widgets/layouts/max_width_body.dart
Normal file
66
lib/widgets/layouts/max_width_body.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/widgets/layouts/two_column_layout.dart
Normal file
43
lib/widgets/layouts/two_column_layout.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/widgets/local_notifications_extension.dart
Normal file
141
lib/widgets/local_notifications_extension.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/widgets/lock_screen.dart
Normal file
198
lib/widgets/lock_screen.dart
Normal 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
97
lib/widgets/log_view.dart
Normal 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
435
lib/widgets/matrix.dart
Normal 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 }
|
||||
377
lib/widgets/member_actions_popup_menu_button.dart
Normal file
377
lib/widgets/member_actions_popup_menu_button.dart
Normal 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
266
lib/widgets/mxc_image.dart
Normal 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;
|
||||
}
|
||||
64
lib/widgets/mxc_image_viewer.dart
Normal file
64
lib/widgets/mxc_image_viewer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/widgets/permission_slider_dialog.dart
Normal file
66
lib/widgets/permission_slider_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
60
lib/widgets/presence_builder.dart
Normal file
60
lib/widgets/presence_builder.dart
Normal 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);
|
||||
}
|
||||
69
lib/widgets/pulsating_widget.dart
Normal file
69
lib/widgets/pulsating_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
144
lib/widgets/qr_code_viewer.dart
Normal file
144
lib/widgets/qr_code_viewer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/widgets/settings_switch_list_tile.dart
Normal file
44
lib/widgets/settings_switch_list_tile.dart
Normal 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(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/widgets/share_scaffold_dialog.dart
Normal file
215
lib/widgets/share_scaffold_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/widgets/tatu_chat_app.dart
Normal file
102
lib/widgets/tatu_chat_app.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/theme_builder.dart
Normal file
103
lib/widgets/theme_builder.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/widgets/typing_animation.dart
Normal file
71
lib/widgets/typing_animation.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/widgets/unread_rooms_badge.dart
Normal file
48
lib/widgets/unread_rooms_badge.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/widgets/view_model_builder.dart
Normal file
48
lib/widgets/view_model_builder.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user