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

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

View File

@@ -0,0 +1,221 @@
// 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:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/file_selector.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.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:tatchat/widgets/future_loading_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import '../../widgets/matrix.dart';
import 'settings_view.dart';
class Settings extends StatefulWidget {
const Settings({super.key});
@override
SettingsController createState() => SettingsController();
}
class SettingsController extends State<Settings> {
Future<Profile>? profileFuture;
bool profileUpdated = false;
void updateProfile() => setState(() {
profileUpdated = true;
profileFuture = null;
});
Future<void> setDisplaynameAction() async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final profile = await profileFuture;
if (!mounted) return;
final input = await showTextInputDialog(
useRootNavigator: false,
context: context,
title: l10n.editDisplayname,
okLabel: l10n.ok,
cancelLabel: l10n.cancel,
initialText: profile?.displayName ?? matrix.client.userID!.localpart,
);
if (input == null) return;
if (!mounted) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.setProfileField(
matrix.client.userID!,
'displayname',
{'displayname': input},
),
);
if (success.error == null) {
updateProfile();
}
}
Future<void> logoutAction() async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final consent = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: l10n.areYouSureYouWantToLogout,
message: l10n.noBackupWarning,
isDestructive: cryptoIdentityConnected == false,
okLabel: l10n.logout,
cancelLabel: l10n.cancel,
);
if (consent != OkCancelResult.ok) return;
if (!mounted) return;
await showFutureLoadingDialog(
context: context,
future: () => matrix.client.logout(),
);
if (!mounted) return;
context.go('/');
}
Future<void> setAvatarAction() async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final profile = await profileFuture;
if (!mounted) return;
final actions = [
if (PlatformInfos.isMobile)
AdaptiveModalAction(
value: AvatarAction.camera,
label: l10n.openCamera,
isDefaultAction: true,
icon: const Icon(Icons.camera_alt_outlined),
),
AdaptiveModalAction(
value: AvatarAction.file,
label: l10n.openGallery,
icon: const Icon(Icons.photo_outlined),
),
if (profile?.avatarUrl != null)
AdaptiveModalAction(
value: AvatarAction.remove,
label: l10n.removeYourAvatar,
isDestructive: true,
icon: const Icon(Icons.delete_outlined),
),
];
final action = actions.length == 1
? actions.single.value
: await showModalActionPopup<AvatarAction>(
context: context,
title: l10n.changeYourAvatar,
cancelLabel: l10n.cancel,
actions: actions,
);
if (action == null) return;
if (!mounted) return;
if (action == AvatarAction.remove) {
final success = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.setAvatar(null),
);
if (success.error == null) {
updateProfile();
}
return;
}
MatrixFile file;
if (PlatformInfos.isMobile) {
final result = await ImagePicker().pickImage(
source: action == AvatarAction.camera
? ImageSource.camera
: ImageSource.gallery,
imageQuality: 50,
);
if (result == null) return;
file = MatrixFile(bytes: await result.readAsBytes(), name: result.path);
} else {
if (!mounted) return;
final result = await selectFiles(context, type: FileType.image);
final pickedFile = result.firstOrNull;
if (pickedFile == null) return;
file = MatrixFile(
bytes: await pickedFile.readAsBytes(),
name: pickedFile.name,
);
}
if (!mounted) return;
final success = await showFutureLoadingDialog(
context: context,
future: () => matrix.client.setAvatar(file),
);
if (success.error == null) {
updateProfile();
}
}
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) => checkBootstrap());
super.initState();
}
Future<void> checkBootstrap() async {
final client = Matrix.of(context).client;
if (!client.encryptionEnabled) return;
if (!client.isLogged()) return;
await client.accountDataLoading;
await client.userDeviceKeysLoading;
if (client.prevBatch == null) {
await client.onSync.stream.first;
}
final state = await client.getCryptoIdentityState();
if (!mounted) return;
setState(() {
cryptoIdentityConnected = state.initialized && state.connected;
});
}
bool? cryptoIdentityConnected;
Future<void> firstRunBootstrapAction([_]) async {
if (cryptoIdentityConnected == true) {
final action = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).chatBackup,
message: L10n.of(context).onlineKeyBackupEnabled,
okLabel: L10n.of(context).resetRecoveryKey,
cancelLabel: L10n.of(context).close,
isDestructive: true,
);
if (action != OkCancelResult.ok) return;
if (!mounted) return;
await context.push('/backup?reset=true');
return;
}
await context.push('/backup');
checkBootstrap();
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
profileFuture ??= client.getProfileFromUserId(client.userID!);
return SettingsView(this);
}
}
enum AvatarAction { camera, file, remove }

View File

@@ -0,0 +1,219 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:async/async.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/utils/platform_infos.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' hide Result;
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../widgets/mxc_image_viewer.dart';
import 'settings.dart';
class SettingsView extends StatelessWidget {
final SettingsController controller;
const SettingsView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeRoute = GoRouter.of(
context,
).routeInformationProvider.value.uri.path;
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).settings),
leading: Center(
child: BackButton(onPressed: () => context.go('/rooms')),
),
),
body: ListTileTheme(
iconColor: theme.colorScheme.onSurface,
child: ListView(
key: const Key('SettingsListViewContent'),
children: <Widget>[
FutureBuilder<Profile>(
future: controller.profileFuture,
builder: (context, snapshot) {
final profile = snapshot.data;
final avatar = profile?.avatarUrl;
final mxid =
Matrix.of(context).client.userID ?? L10n.of(context).user;
final displayname =
profile?.displayName ?? mxid.localpart ?? mxid;
return Column(
crossAxisAlignment: .center,
mainAxisSize: .min,
children: [
Stack(
children: [
Avatar(
mxContent: avatar,
name: displayname,
size: Avatar.defaultSize * 2.5,
onTap: avatar != null
? () => showDialog(
context: context,
builder: (_) => MxcImageViewer(avatar),
)
: null,
),
if (profile != null)
Positioned(
bottom: 0,
right: 0,
child: FloatingActionButton.small(
elevation: 2,
onPressed: controller.setAvatarAction,
heroTag: null,
child: const Icon(Icons.camera_alt_outlined),
),
),
],
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: controller.setDisplaynameAction,
icon: const Icon(Icons.edit_outlined, size: 16),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onSurface,
iconColor: theme.colorScheme.onSurface,
),
label: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18),
),
),
TextButton(
onPressed: () => TatuShare.share(mxid, context),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.secondary,
iconColor: theme.colorScheme.secondary,
),
child: Text(
mxid,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
],
);
},
),
SwitchListTile.adaptive(
controlAffinity: ListTileControlAffinity.trailing,
value: controller.cryptoIdentityConnected == true,
secondary: const Icon(Icons.backup_outlined),
title: Text(L10n.of(context).chatBackup),
onChanged: controller.firstRunBootstrapAction,
contentPadding: EdgeInsets.only(left: 16, right: 8),
),
FutureBuilder(
future: Result.capture(
Matrix.of(context).client.getAuthMetadata(),
).then((result) => result.asValue?.value),
builder: (context, snapshot) {
final accountManageUrl = snapshot.data?.accountManagementUri;
if (accountManageUrl == null) {
return const SizedBox.shrink();
}
return ListTile(
leading: const Icon(Icons.account_circle_outlined),
title: Text(L10n.of(context).manageAccount),
trailing: const Icon(Icons.open_in_new_outlined),
onTap: () => launchUrl(
accountManageUrl,
mode: LaunchMode.inAppBrowserView,
),
);
},
),
ListTile(
leading: const Icon(Icons.format_paint_outlined),
title: Text(L10n.of(context).changeTheme),
tileColor: activeRoute.startsWith('/rooms/settings/style')
? theme.colorScheme.surfaceContainerHigh
: null,
onTap: () => context.go('/rooms/settings/style'),
),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: Text(L10n.of(context).notifications),
tileColor: activeRoute.startsWith('/rooms/settings/notifications')
? theme.colorScheme.surfaceContainerHigh
: null,
onTap: () => context.go('/rooms/settings/notifications'),
),
ListTile(
leading: const Icon(Icons.devices_outlined),
title: Text(L10n.of(context).devices),
onTap: () => context.go('/rooms/settings/devices'),
tileColor: activeRoute.startsWith('/rooms/settings/devices')
? theme.colorScheme.surfaceContainerHigh
: null,
),
ListTile(
leading: const Icon(Icons.forum_outlined),
title: Text(L10n.of(context).chat),
onTap: () => context.go('/rooms/settings/chat'),
tileColor: activeRoute.startsWith('/rooms/settings/chat')
? theme.colorScheme.surfaceContainerHigh
: null,
),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: Text(L10n.of(context).security),
onTap: () => context.go('/rooms/settings/security'),
tileColor: activeRoute.startsWith('/rooms/settings/security')
? theme.colorScheme.surfaceContainerHigh
: null,
),
Divider(color: theme.dividerColor),
ListTile(
leading: const Icon(Icons.dns_outlined),
title: Text(
L10n.of(context).aboutHomeserver(
Matrix.of(context).client.userID?.domain ?? 'homeserver',
),
),
onTap: () => context.go('/rooms/settings/homeserver'),
tileColor: activeRoute.startsWith('/rooms/settings/homeserver')
? theme.colorScheme.surfaceContainerHigh
: null,
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: Text(L10n.of(context).privacy),
onTap: () => launchUrlString(AppSettings.privacyPolicy.value),
),
ListTile(
leading: const Icon(Icons.info_outline_rounded),
title: Text(L10n.of(context).about),
onTap: () => PlatformInfos.showDialog(context),
),
Divider(color: theme.dividerColor),
ListTile(
leading: const Icon(Icons.logout_outlined),
title: Text(L10n.of(context).logout),
onTap: controller.logoutAction,
),
],
),
),
);
}
}