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,179 @@
// 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' show Result;
import 'package:collection/collection.dart' show IterableExtension;
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/device_settings/device_settings_view.dart';
import 'package:tatchat/pages/key_verification/key_verification_dialog.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:material_ui/material_ui.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:url_launcher/url_launcher_string.dart';
import '../../widgets/matrix.dart';
class DevicesSettings extends StatefulWidget {
const DevicesSettings({super.key});
@override
DevicesSettingsController createState() => DevicesSettingsController();
}
class DevicesSettingsController extends State<DevicesSettings> {
List<Device>? devices;
Future<bool> loadUserDevices(BuildContext context) async {
if (devices != null) return true;
devices = await Matrix.of(context).client.getDevices();
return true;
}
void reload() => setState(() => devices = null);
bool? chatBackupEnabled;
@override
void initState() {
_checkChatBackup();
super.initState();
}
Future<void> _checkChatBackup() async {
final client = Matrix.of(context).client;
final state = await client.getCryptoIdentityState();
if (!mounted) return;
setState(() {
chatBackupEnabled = state.initialized && !state.connected;
});
}
Future<void> removeDevicesAction(List<Device> devices) async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final client = Matrix.of(context).client;
final wellKnown = await Result.capture(client.getWellknown());
final accountManageUrl = wellKnown.asValue?.value.additionalProperties
.tryGetMap<String, Object?>('org.matrix.msc2965.authentication')
?.tryGet<String>('account');
if (accountManageUrl != null) {
launchUrlString(accountManageUrl, mode: LaunchMode.inAppBrowserView);
return;
}
if (!mounted) return;
if (await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
okLabel: l10n.remove,
cancelLabel: l10n.cancel,
message: l10n.removeDevicesDescription,
isDestructive: true,
) ==
OkCancelResult.cancel) {
return;
}
if (!mounted) return;
final deviceIds = <String>[];
for (final userDevice in devices) {
deviceIds.add(userDevice.deviceId);
}
await showFutureLoadingDialog(
context: context,
delay: false,
future: () => matrix.client.uiaRequestBackground(
(auth) => matrix.client.deleteDevices(deviceIds, auth: auth),
),
);
reload();
}
Future<void> renameDeviceAction(Device device) async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final displayName = await showTextInputDialog(
context: context,
title: l10n.changeDeviceName,
okLabel: l10n.ok,
cancelLabel: l10n.cancel,
hintText: device.displayName,
);
if (displayName == null) return;
if (!mounted) return;
final success = await showFutureLoadingDialog(
context: context,
future: () =>
matrix.client.updateDevice(device.deviceId, displayName: displayName),
);
if (success.error == null) {
reload();
}
}
Future<void> verifyDeviceAction(Device device) async {
final l10n = L10n.of(context);
final matrix = Matrix.of(context);
final consent = await showOkCancelAlertDialog(
context: context,
title: l10n.verifyOtherDevice,
message: l10n.verifyOtherDeviceDescription,
okLabel: l10n.ok,
cancelLabel: l10n.cancel,
);
if (consent != OkCancelResult.ok) return;
if (!mounted) return;
final req = await matrix
.client
.userDeviceKeys[matrix.client.userID!]!
.deviceKeys[device.deviceId]!
.startVerification();
req.onUpdate = () {
if ({
KeyVerificationState.error,
KeyVerificationState.done,
}.contains(req.state)) {
setState(() {});
}
};
if (!mounted) return;
await KeyVerificationDialog(request: req).show(context);
}
Future<void> blockDeviceAction(Device device) async {
final key = Matrix.of(context)
.client
.userDeviceKeys[Matrix.of(context).client.userID!]!
.deviceKeys[device.deviceId]!;
if (key.directVerified) {
await key.setVerified(false);
}
await key.setBlocked(true);
setState(() {});
}
Future<void> unblockDeviceAction(Device device) async {
final key = Matrix.of(context)
.client
.userDeviceKeys[Matrix.of(context).client.userID!]!
.deviceKeys[device.deviceId]!;
await key.setBlocked(false);
setState(() {});
}
bool _isOwnDevice(Device userDevice) =>
userDevice.deviceId == Matrix.of(context).client.deviceID;
Device? get thisDevice => devices!.firstWhereOrNull(_isOwnDevice);
List<Device> get notThisDevice => List<Device>.from(devices!)
..removeWhere(_isOwnDevice)
..sort((a, b) => (b.lastSeenTs ?? 0).compareTo(a.lastSeenTs ?? 0));
@override
Widget build(BuildContext context) => DevicesSettingsView(this);
}

View File

@@ -0,0 +1,148 @@
// 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/pages/device_settings/device_settings.dart';
import 'package:tatchat/widgets/layouts/max_width_body.dart';
import 'package:material_ui/material_ui.dart';
import 'user_device_list_item.dart';
class DevicesSettingsView extends StatelessWidget {
final DevicesSettingsController controller;
const DevicesSettingsView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !FluffyThemes.isColumnMode(context),
centerTitle: FluffyThemes.isColumnMode(context),
title: Text(L10n.of(context).devices),
),
body: MaxWidthBody(
child: FutureBuilder<bool>(
future: controller.loadUserDevices(context),
builder: (BuildContext context, snapshot) {
final theme = Theme.of(context);
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: .min,
children: <Widget>[
const Icon(Icons.error_outlined),
Text(snapshot.error.toString()),
],
),
);
}
if (!snapshot.hasData || controller.devices == null) {
return const Center(
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.notThisDevice.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return Column(
mainAxisSize: .min,
children: [
if (controller.chatBackupEnabled == false)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.info_outlined),
),
subtitle: Text(
L10n.of(
context,
).noticeChatBackupDeviceVerification,
),
),
),
if (controller.thisDevice != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
alignment: Alignment.centerLeft,
child: Text(
L10n.of(context).thisDevice,
style: TextStyle(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.left,
),
),
UserDeviceListItem(
controller.thisDevice!,
rename: controller.renameDeviceAction,
remove: (d) => controller.removeDevicesAction([d]),
verify: controller.verifyDeviceAction,
block: controller.blockDeviceAction,
unblock: controller.unblockDeviceAction,
),
],
if (controller.notThisDevice.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: SizedBox(
width: double.infinity,
child: TextButton.icon(
label: Text(
L10n.of(context).removeAllOtherDevices,
),
style: TextButton.styleFrom(
iconColor: theme.colorScheme.onErrorContainer,
foregroundColor:
theme.colorScheme.onErrorContainer,
backgroundColor:
theme.colorScheme.errorContainer,
),
icon: const Icon(Icons.delete_outline),
onPressed: () => controller.removeDevicesAction(
controller.notThisDevice,
),
),
),
)
else
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(L10n.of(context).noOtherDevicesFound),
),
),
],
);
}
i--;
return UserDeviceListItem(
controller.notThisDevice[i],
rename: controller.renameDeviceAction,
remove: (d) => controller.removeDevicesAction([d]),
verify: controller.verifyDeviceAction,
block: controller.blockDeviceAction,
unblock: controller.unblockDeviceAction,
);
},
);
},
),
),
);
}
}

View File

@@ -0,0 +1,154 @@
// 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/widgets/adaptive_dialogs/show_modal_action_popup.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../utils/date_time_extension.dart';
import '../../utils/matrix_sdk_extensions/device_extension.dart';
import '../../widgets/matrix.dart';
enum UserDeviceListItemAction { rename, remove, verify, block, unblock }
class UserDeviceListItem extends StatelessWidget {
final Device userDevice;
final void Function(Device) remove;
final void Function(Device) rename;
final void Function(Device) verify;
final void Function(Device) block;
final void Function(Device) unblock;
const UserDeviceListItem(
this.userDevice, {
required this.remove,
required this.rename,
required this.verify,
required this.block,
required this.unblock,
super.key,
});
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final keys = client
.userDeviceKeys[Matrix.of(context).client.userID]
?.deviceKeys[userDevice.deviceId];
final isOwnDevice = userDevice.deviceId == client.deviceID;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Material(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
onTap: () async {
final action = await showModalActionPopup<UserDeviceListItemAction>(
context: context,
title: '${userDevice.displayName} (${userDevice.deviceId})',
cancelLabel: L10n.of(context).cancel,
actions: [
AdaptiveModalAction(
value: UserDeviceListItemAction.rename,
icon: const Icon(Icons.edit_outlined),
label: L10n.of(context).changeDeviceName,
),
if (!isOwnDevice && keys != null) ...{
AdaptiveModalAction(
value: UserDeviceListItemAction.verify,
icon: const Icon(Icons.verified_outlined),
label: L10n.of(context).verifyStart,
),
if (!keys.blocked)
AdaptiveModalAction(
value: UserDeviceListItemAction.block,
icon: const Icon(Icons.block_outlined),
label: L10n.of(context).blockDevice,
isDestructive: true,
),
if (keys.blocked)
AdaptiveModalAction(
value: UserDeviceListItemAction.unblock,
icon: const Icon(Icons.block),
label: L10n.of(context).unblockDevice,
isDestructive: true,
),
},
if (!isOwnDevice)
AdaptiveModalAction(
value: UserDeviceListItemAction.remove,
icon: const Icon(Icons.delete_outlined),
label: L10n.of(context).delete,
isDestructive: true,
),
],
);
if (action == null) return;
switch (action) {
case UserDeviceListItemAction.rename:
rename(userDevice);
break;
case UserDeviceListItemAction.remove:
remove(userDevice);
break;
case UserDeviceListItemAction.verify:
verify(userDevice);
break;
case UserDeviceListItemAction.block:
block(userDevice);
break;
case UserDeviceListItemAction.unblock:
unblock(userDevice);
break;
}
},
leading: CircleAvatar(
foregroundColor: Colors.white,
backgroundColor: keys == null
? Colors.grey[700]
: keys.blocked
? Colors.red
: keys.verified
? Colors.green
: Colors.orange,
child: Icon(userDevice.icon),
),
title: Text(
userDevice.displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
L10n.of(context).lastActiveAgo(
DateTime.fromMillisecondsSinceEpoch(
userDevice.lastSeenTs ?? 0,
).localizedTimeShort(context),
),
style: const TextStyle(fontWeight: FontWeight.w300),
),
trailing: keys == null
? null
: Text(
keys.blocked
? L10n.of(context).blocked
: keys.verified
? L10n.of(context).verified
: L10n.of(context).unverified,
style: TextStyle(
color: keys.blocked
? Colors.red
: keys.verified
? Colors.green
: Colors.orange,
),
),
),
),
);
}
}