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,68 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:ui';
abstract class AppConfig {
static const Color primaryColor = Color(0xFF00D084);
static const Color chatColor = primaryColor;
static const double messageFontSize = 16.0;
static const bool allowOtherHomeservers = true;
static const bool enableRegistration = true;
static const bool hideTypingUsernames = false;
static const String inviteLinkPrefix = 'https://matrix.to/#/';
static const String deepLinkPrefix = 'im.tatuchat://chat/';
static const String schemePrefix = 'matrix:';
static const String pushNotificationsChannelId = 'tatuchat_push';
static const String pushNotificationsAppId = 'chat.fluffy.tatuchat';
static const double borderRadius = 18.0;
static const double spaceBorderRadius = 11.0;
static const double columnWidth = 360.0;
static const String enablePushTutorial =
'https://ko-fi.com/post/How-can-I-get-Push-Notifications-without-Google-N7Q825URG6?fromEditor=true';
static const String encryptionTutorial =
'https://ko-fi.com/post/How-to-use-end-to-end-encryption-in-FluffyChat-A5O725WDR5';
static const String howDoIGetStickersTutorial =
'https://ko-fi.com/post/How-to-add-a-sticker-pack-to-FluffyChat-N4N01OXATI';
static const String appId = 'im.tatuchat.TatuChat';
static const String appOpenUrlScheme = 'im.tatuchat';
static const String appSsoUrlScheme = 'im.tatuchat.auth';
static const String sourceCodeUrl =
'https://github.com/krille-chan/fluffychat';
static const String supportUrl =
'https://github.com/krille-chan/fluffychat/issues';
static const String changelogUrl = 'https://fluffychat.im/changelog/';
static const String helpUrl =
'https://ko-fi.com/post/How-can-I-support-FluffyChat-J2G325WE6I';
static const Set<String> defaultReactions = {'👍', '❤️', '😂', '😮', '😢'};
static final Uri newIssueUrl = Uri(
scheme: 'https',
host: 'github.com',
path: '/krille-chan/fluffychat/issues/new',
);
static final Uri homeserverList = Uri(
scheme: 'https',
host: 'raw.githubusercontent.com',
path: 'krille-chan/fluffychat/refs/heads/main/recommended_homeservers.json',
);
static final Uri crashReportEndpoint = Uri(
scheme: 'https',
host: 'crash.fluffy.chat',
);
static const String mainIsolatePortName = 'main_isolate';
static const String pushIsolatePortName = 'push_isolate';
static const String pushHelperCrashReportKey = 'push_helper_crash_report';
static const String vodozemacVersion = '0.8.1';
}

37
lib/config/isrg_x1.dart Normal file
View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// ignore: constant_identifier_names
const String ISRG_X1 = '''-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----''';

20
lib/config/isrg_x2.dart Normal file
View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// ignore: constant_identifier_names
const String ISRG_X2 = '''-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----''';

551
lib/config/routes.dart Normal file
View File

@@ -0,0 +1,551 @@
// 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:tatchat/pages/archive/archive.dart';
import 'package:tatchat/pages/bootstrap/bootstrap_page.dart';
import 'package:tatchat/pages/chat/chat.dart';
import 'package:tatchat/pages/chat_access_settings/chat_access_settings_controller.dart';
import 'package:tatchat/pages/chat_details/chat_details.dart';
import 'package:tatchat/pages/chat_encryption_settings/chat_encryption_settings.dart';
import 'package:tatchat/pages/chat_list/chat_list.dart';
import 'package:tatchat/pages/chat_members/chat_members.dart';
import 'package:tatchat/pages/chat_permissions_settings/chat_permissions_settings.dart';
import 'package:tatchat/pages/chat_search/chat_search_page.dart';
import 'package:tatchat/pages/device_settings/device_settings.dart';
import 'package:tatchat/pages/intro/intro_page_presenter.dart';
import 'package:tatchat/pages/invitation_selection/invitation_selection.dart';
import 'package:tatchat/pages/login/login.dart';
import 'package:tatchat/pages/new_group/new_group.dart';
import 'package:tatchat/pages/new_private_chat/new_private_chat.dart';
import 'package:tatchat/pages/register/registration.dart';
import 'package:tatchat/pages/settings/settings.dart';
import 'package:tatchat/pages/settings_3pid/settings_3pid.dart';
import 'package:tatchat/pages/settings_chat/settings_chat.dart';
import 'package:tatchat/pages/settings_emotes/settings_emotes.dart';
import 'package:tatchat/pages/settings_homeserver/settings_homeserver.dart';
import 'package:tatchat/pages/settings_ignore_list/settings_ignore_list.dart';
import 'package:tatchat/pages/settings_notifications/settings_notifications.dart';
import 'package:tatchat/pages/settings_password/settings_password.dart';
import 'package:tatchat/pages/settings_security/settings_security.dart';
import 'package:tatchat/pages/settings_style/settings_style.dart';
import 'package:tatchat/pages/sign_in/sign_in_page.dart';
import 'package:tatchat/widgets/config_viewer.dart';
import 'package:tatchat/widgets/layouts/empty_page.dart';
import 'package:tatchat/widgets/layouts/two_column_layout.dart';
import 'package:tatchat/widgets/log_view.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
abstract class AppRoutes {
static FutureOr<String?> loggedInRedirect(
BuildContext context,
GoRouterState state,
) => Matrix.of(context).widget.clients.any((client) => client.isLogged())
? '/rooms'
: null;
static FutureOr<String?> loggedOutRedirect(
BuildContext context,
GoRouterState state,
) => Matrix.of(context).widget.clients.any((client) => client.isLogged())
? null
: '/home';
AppRoutes();
static final List<RouteBase> routes = [
GoRoute(
path: '/',
redirect: (context, state) =>
Matrix.of(context).widget.clients.any((client) => client.isLogged())
? '/rooms'
: '/home',
),
GoRoute(
path: '/home',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const IntroPagePresenter()),
redirect: loggedInRedirect,
routes: [
GoRoute(
path: 'sign_in',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, SignInPage(signUp: false)),
redirect: loggedInRedirect,
),
GoRoute(
path: 'sign_up',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, SignInPage(signUp: true)),
redirect: loggedInRedirect,
),
GoRoute(
path: 'login',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
Login(client: state.extra as Client),
),
redirect: loggedInRedirect,
),
GoRoute(
path: 'register',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
RegistrationPage(client: state.extra as Client),
),
redirect: loggedInRedirect,
),
],
),
GoRoute(
path: '/logs',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const LogViewer()),
),
GoRoute(
path: '/configs',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const ConfigViewer()),
),
GoRoute(
path: '/backup',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
BootstrapPage(reset: state.uri.queryParameters['reset'] == 'true'),
),
),
ShellRoute(
// Never use a transition on the shell route. Changing the PageBuilder
// here based on a MediaQuery causes the child to briefly be rendered
// twice with the same GlobalKey, blowing up the rendering.
pageBuilder: (context, state, child) => noTransitionPageBuilder(
context,
state,
FluffyThemes.isColumnMode(context) &&
state.fullPath?.startsWith('/rooms/settings') == false
? TwoColumnLayout(
mainView: ChatList(
activeChat: state.pathParameters['roomid'],
activeSpace: state.uri.queryParameters['spaceId'],
displayNavigationRail:
state.path?.startsWith('/rooms/settings') != true,
),
sideView: child,
)
: child,
),
routes: [
GoRoute(
path: '/rooms',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
FluffyThemes.isColumnMode(context)
? const EmptyPage()
: ChatList(
activeChat: state.pathParameters['roomid'],
activeSpace: state.uri.queryParameters['spaceId'],
),
),
routes: [
GoRoute(
path: 'archive',
pageBuilder: (context, state) =>
defaultPageBuilder(context, state, const Archive()),
routes: [
GoRoute(
path: ':roomid',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatPage(
key: ValueKey(state.uri),
roomId: state.pathParameters['roomid']!,
eventId: state.uri.queryParameters['event'],
timeline: state.extra as Timeline?,
action: state.uri.queryParameters['action'],
),
),
redirect: loggedOutRedirect,
),
],
redirect: loggedOutRedirect,
),
GoRoute(
path: 'newprivatechat',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
NewPrivateChat(
key: ValueKey('new_chat_${state.uri.fragment}'),
deeplink: state.uri.fragment,
),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'newgroup',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
NewGroup(spaceId: state.uri.queryParameters['space_id']),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'newspace',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
NewGroup(
createGroupType: CreateGroupType.space,
spaceId: state.uri.queryParameters['space_id'],
),
),
redirect: loggedOutRedirect,
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
FluffyThemes.isColumnMode(context)
? TwoColumnLayout(
mainView: Settings(key: state.pageKey),
sideView: child,
hasNavigationRail: false,
)
: child,
),
routes: [
GoRoute(
path: 'settings',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
FluffyThemes.isColumnMode(context)
? const EmptyPage()
: const Settings(),
),
routes: [
GoRoute(
path: 'notifications',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SettingsNotifications(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'style',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SettingsStyle(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'devices',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const DevicesSettings(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'chat',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SettingsChat(),
),
routes: [
GoRoute(
path: 'emotes',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
EmotesSettings(
roomId: state.pathParameters['roomid'],
),
),
),
],
redirect: loggedOutRedirect,
),
GoRoute(
path: 'addaccount',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const IntroPagePresenter(),
),
routes: [
GoRoute(
path: 'sign_in',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
SignInPage(signUp: false),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'sign_up',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
SignInPage(signUp: true),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'login',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
Login(client: state.extra as Client),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'register',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
RegistrationPage(client: state.extra as Client),
),
redirect: loggedOutRedirect,
),
],
),
GoRoute(
path: 'homeserver',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
const SettingsHomeserver(),
);
},
redirect: loggedOutRedirect,
),
GoRoute(
path: 'security',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SettingsSecurity(),
),
routes: [
GoRoute(
path: 'password',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
const SettingsPassword(),
);
},
redirect: loggedOutRedirect,
),
GoRoute(
path: 'ignorelist',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
SettingsIgnoreList(
initialUserId: state.extra?.toString(),
),
);
},
redirect: loggedOutRedirect,
),
GoRoute(
path: '3pid',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const Settings3Pid(),
),
redirect: loggedOutRedirect,
),
],
),
],
redirect: loggedOutRedirect,
),
],
),
GoRoute(
path: ':roomid',
pageBuilder: (context, state) {
final body = state.uri.queryParameters['body'];
var shareItems = state.extra is List<ShareItem>
? state.extra as List<ShareItem>
: null;
if (body != null && body.isNotEmpty) {
shareItems ??= [];
shareItems.add(TextShareItem(body));
}
return defaultPageBuilder(
context,
state,
ChatPage(
key: ValueKey(state.uri),
roomId: state.pathParameters['roomid']!,
shareItems: shareItems,
eventId: state.uri.queryParameters['event'],
action: state.uri.queryParameters['action'],
),
);
},
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: 'search',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatSearchPage(roomId: state.pathParameters['roomid']!),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'encryption',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const ChatEncryptionSettings(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'invite',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
InvitationSelection(
roomId: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'details',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatDetails(roomId: state.pathParameters['roomid']!),
),
routes: [
GoRoute(
path: 'access',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatAccessSettings(
roomId: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'members',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
ChatMembersPage(
roomId: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'permissions',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const ChatPermissionsSettings(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'invite',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
InvitationSelection(
roomId: state.pathParameters['roomid']!,
),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'emotes',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
EmotesSettings(roomId: state.pathParameters['roomid']),
),
redirect: loggedOutRedirect,
),
],
redirect: loggedOutRedirect,
),
],
),
],
),
],
),
];
static Page noTransitionPageBuilder(
BuildContext context,
GoRouterState state,
Widget child,
) => NoTransitionPage(
key: state.pageKey,
restorationId: state.pageKey.value,
child: child,
);
static Page defaultPageBuilder(
BuildContext context,
GoRouterState state,
Widget child,
) {
final clientName = state.uri.queryParameters['client'];
if (clientName != null) {
final matrix = Matrix.of(context);
final client = matrix.getClientByName(clientName);
if (client != null) matrix.setActiveClient(client);
}
return FluffyThemes.isColumnMode(context)
? noTransitionPageBuilder(context, state, child)
: MaterialPage(
key: state.pageKey,
restorationId: state.pageKey.value,
child: child,
);
}
}

View File

@@ -0,0 +1,264 @@
// 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 'package:async/async.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:managed_configurations/managed_configurations.dart';
import 'package:matrix/matrix_api_lite/utils/logs.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum AppSettings<T> {
textMessageMaxLength<int>('textMessageMaxLength', 16384),
/// Max lines for unselected HTML/text bubbles; 0 = unlimited (no fade).
messagePreviewMaxLines<int>('chat.fluffy.message_preview_max_lines', 50),
audioRecordingNumChannels<int>('audioRecordingNumChannels', 1),
audioRecordingAutoGain<bool>('audioRecordingAutoGain', true),
audioRecordingEchoCancel<bool>('audioRecordingEchoCancel', false),
audioRecordingNoiseSuppress<bool>('audioRecordingNoiseSuppress', true),
audioRecordingBitRate<int>('audioRecordingBitRate', 64000),
audioRecordingSamplingRate<int>('audioRecordingSamplingRate', 44100),
showNoGoogle<bool>('chat.fluffy.show_no_google', false),
unifiedPushRegistered<bool>('chat.fluffy.unifiedpush.registered', false),
unifiedPushEndpoint<String>('chat.fluffy.unifiedpush.endpoint', ''),
pushNotificationsGatewayUrl<String>(
'pushNotificationsGatewayUrl',
'https://push.fluffychat.im/_matrix/push/v1/notify',
),
pushNotificationsPusherFormat<String>(
'pushNotificationsPusherFormat',
'event_id_only',
),
renderHtml<bool>('chat.fluffy.renderHtml', true),
fontSizeFactor<double>('chat.fluffy.font_size_factor', 1.0),
hideRedactedEvents<bool>('chat.fluffy.hideRedactedEvents', false),
hideUnknownEvents<bool>('chat.fluffy.hideUnknownEvents', true),
autoplayImages<bool>('chat.fluffy.autoplay_images', true),
sendTypingNotifications<bool>('chat.fluffy.send_typing_notifications', true),
sendPublicReadReceipts<bool>('chat.fluffy.send_public_read_receipts', true),
swipeRightToLeftToReply<bool>('chat.fluffy.swipeRightToLeftToReply', true),
sendOnEnter<bool>('chat.fluffy.send_on_enter', false),
displayNavigationRail<bool>('chat.fluffy.display_navigation_rail', false),
shareKeysWith<String>('chat.fluffy.share_keys_with_2', 'all'),
noEncryptionWarningShown<bool>(
'chat.fluffy.no_encryption_warning_shown',
false,
),
displayChatDetailsColumn('chat.fluffy.display_chat_details_column', false),
// AppConfig-mirrored settings
applicationName<String>('chat.fluffy.application_name', 'TatuChat'),
defaultHomeserver<String>(
'chat.fluffy.default_homeserver',
'https://matrix.weltonmoura.com.br',
),
// colorSchemeSeed stored as ARGB int
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084),
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),
presetHomeserver<String>(
'chat.fluffy.preset_homeserver',
'https://matrix.weltonmoura.com.br',
),
welcomeText<String>('chat.fluffy.welcome_text', ''),
website<String>('chat.fluffy.website_url', 'https://fluffychat.im'),
logoUrl<String>(
'chat.fluffy.logo_url',
'https://fluffychat.im/assets/favicon.png',
),
privacyPolicy<String>(
'chat.fluffy.privacy_policy_url',
'https://fluffychat.im/privacy',
),
tos<String>('chat.fluffy.tos_url', 'https://fluffychat.im/tos'),
sendTimelineEventTimeout<int>('chat.fluffy.send_timeline_event_timeout', 15),
webNotificationSound<bool>('chat.fluffy.web_notification_sound', true),
chatFilter<String>('chat.fluffy.chat_filter', 'allChats'),
hideRoomsInSpaces<bool>('chat.fluffy.hideRoomsInSpaces', false),
showThumbnailsInTimeline<bool>('chat.fluffy.showThumbnailsInTimeline', true),
doubleTapToReact<bool>('chat.fluffy.double_tap_to_react', false),
doubleTapReaction<String>('chat.fluffy.double_tap_reaction', '❤️'),
benchmarksInLogs<bool>('chat.fluffy.benchmarks_in_logs', false),
autoSendErrorReports<bool?>('chat.fluffy.auto_send_eror_reports', null),
knownErrorHashes<List<String>>('chat.fluffy.known_crash_hashes', []),
customLiveKitInstance<String>('chat.fluffy.custom_live_kit_instance', '');
final String key;
final T _defaultValue;
const AppSettings(this.key, this._defaultValue);
static SharedPreferences get store => _store!;
static SharedPreferences? _store;
static Map<String, Object?>? _platformConfiguration;
T get defaultValue {
final platformConfig = _platformConfiguration?[name];
if (platformConfig is T) return platformConfig;
return _defaultValue;
}
static Future<void> reset({bool loadWebConfigFile = true}) async {
await AppSettings._store!.clear();
await init(loadWebConfigFile: loadWebConfigFile);
}
static Future<SharedPreferences> init({bool loadWebConfigFile = true}) async {
if (AppSettings._store != null) return AppSettings.store;
final store = AppSettings._store = await SharedPreferences.getInstance();
// Migrate wrong datatype for fontSizeFactor
final fontSizeFactorString = Result(
() => store.getString(AppSettings.fontSizeFactor.key),
).asValue?.value;
if (fontSizeFactorString != null) {
Logs().i('Migrate wrong datatype for fontSizeFactor!');
await store.remove(AppSettings.fontSizeFactor.key);
final fontSizeFactor = double.tryParse(fontSizeFactorString);
if (fontSizeFactor != null) {
await store.setDouble(AppSettings.fontSizeFactor.key, fontSizeFactor);
}
}
if (store.getBool(AppSettings.sendOnEnter.key) == null) {
await store.setBool(AppSettings.sendOnEnter.key, !PlatformInfos.isMobile);
}
if (store.getBool(AppSettings.doubleTapToReact.key) == null) {
await store.setBool(
AppSettings.doubleTapToReact.key,
PlatformInfos.isMobile,
);
}
// Load configuration from config.json file or MDM:
if (PlatformInfos.isMobile) {
_platformConfiguration =
await ManagedConfigurations().getManagedConfigurations;
} else if (kIsWeb && loadWebConfigFile) {
try {
final configJsonString = utf8.decode(
(await http.get(Uri.parse('config.json'))).bodyBytes,
);
_platformConfiguration =
json.decode(configJsonString) as Map<String, Object?>;
} on FormatException catch (_) {
Logs().v('[ConfigLoader] config.json not found');
} catch (e) {
Logs().v('[ConfigLoader] config.json not found', e);
}
}
return store;
}
}
extension AppSettingsBoolNExtension on AppSettings<bool?> {
bool? get value {
final value = Result(() => AppSettings.store.getBool(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value;
}
Future<void> setItem(bool value) => AppSettings.store.setBool(key, value);
}
extension AppSettingsBoolExtension on AppSettings<bool> {
bool get value {
final value = Result(() => AppSettings.store.getBool(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value ?? defaultValue;
}
Future<void> setItem(bool value) => AppSettings.store.setBool(key, value);
}
extension AppSettingsStringExtension on AppSettings<String> {
String get value {
final value = Result(() => AppSettings.store.getString(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value ?? defaultValue;
}
Future<void> setItem(String value) => AppSettings.store.setString(key, value);
}
extension AppSettingsIntExtension on AppSettings<int> {
int get value {
final value = Result(() => AppSettings.store.getInt(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value ?? defaultValue;
}
Future<void> setItem(int value) => AppSettings.store.setInt(key, value);
}
extension AppSettingsDoubleExtension on AppSettings<double> {
double get value {
final value = Result(() => AppSettings.store.getDouble(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value ?? defaultValue;
}
Future<void> setItem(double value) => AppSettings.store.setDouble(key, value);
}
extension AppSettingsStringListExtension on AppSettings<List<String>> {
List<String> get value {
final value = Result(() => AppSettings.store.getStringList(key));
final error = value.asError;
if (error != null) {
Logs().e(
'Unable to fetch $key from storage. Removing entry...',
error.error,
error.stackTrace,
);
}
return value.asValue?.value ?? defaultValue;
}
Future<void> setItem(List<String> value) =>
AppSettings.store.setStringList(key, value);
}

View File

@@ -0,0 +1,212 @@
// 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';
/// TatuChat brand palette, adapted between light and dark themes.
///
/// The colors below follow the brand identity:
/// * Primary (brand): Emerald Green #00D084
/// * Background: Deep Graphite #121519 (dark mode)
/// * Surfaces / Cards: Slate Dark #1D222A
/// * Accent: Electric Cyan #00E5FF
/// * Text primary: Pure White / Ice #F4F6F8
/// * Text secondary: Cool Gray #8A94A6
/// * Alert / Error: Crimson Rose #FF4D6D
abstract class TatuPalette {
static const Color brandEmerald = Color(0xFF00D084);
static const Color brandGraphite = Color(0xFF121519);
static const Color brandSlate = Color(0xFF1D222A);
static const Color brandCyan = Color(0xFF00E5FF);
static const Color brandIce = Color(0xFFF4F6F8);
static const Color brandCoolGray = Color(0xFF8A94A6);
static const Color brandCrimson = Color(0xFFFF4D6D);
static const _Scheme _dark = _Scheme(
primary: Color(0xFF00D084),
onPrimary: Color(0xFF00281E),
primaryContainer: Color(0xFF0E4133),
onPrimaryContainer: Color(0xFFBDFFE2),
secondary: Color(0xFF8A94A6),
onSecondary: Color(0xFF141A24),
secondaryContainer: Color(0xFF293240),
onSecondaryContainer: Color(0xFFDBE2EE),
tertiary: Color(0xFF00E5FF),
onTertiary: Color(0xFF003B46),
tertiaryContainer: Color(0xFF004A55),
onTertiaryContainer: Color(0xFFA6F3FF),
error: Color(0xFFFF4D6D),
onError: Color(0xFF410013),
errorContainer: Color(0xFF7F0024),
onErrorContainer: Color(0xFFFFD9DF),
surface: Color(0xFF121519),
onSurface: Color(0xFFF4F6F8),
onSurfaceVariant: Color(0xFF9BA5B6),
outline: Color(0xFF6F7B8C),
outlineVariant: Color(0xFF323B46),
inverseSurface: Color(0xFFE8EBF0),
onInverseSurface: Color(0xFF121519),
inversePrimary: Color(0xFF008E5F),
surfaceLowest: Color(0xFF0C0F13),
surfaceLow: Color(0xFF161B21),
surfaceContainer: Color(0xFF1D222A),
surfaceHigh: Color(0xFF232A34),
surfaceHighest: Color(0xFF2A323D),
surfaceDim: Color(0xFF121519),
surfaceBright: Color(0xFF3A414C),
);
static const _Scheme _light = _Scheme(
primary: Color(0xFF00D084),
onPrimary: Color(0xFF00291F),
primaryContainer: Color(0xFFBDFFE4),
onPrimaryContainer: Color(0xFF003A28),
secondary: Color(0xFF5F6B7D),
onSecondary: Color(0xFFFFFFFF),
secondaryContainer: Color(0xFFE2E7EF),
onSecondaryContainer: Color(0xFF1C2430),
tertiary: Color(0xFF00A6C4),
onTertiary: Color(0xFF002A33),
tertiaryContainer: Color(0xFFB7F1FF),
onTertiaryContainer: Color(0xFF00343D),
error: Color(0xFFFF4D6D),
onError: Color(0xFF430013),
errorContainer: Color(0xFFFFDADD),
onErrorContainer: Color(0xFF4B0012),
surface: Color(0xFFF5F7FA),
onSurface: Color(0xFF121519),
onSurfaceVariant: Color(0xFF5F6B7D),
outline: Color(0xFF7A8595),
outlineVariant: Color(0xFFCCD4DD),
inverseSurface: Color(0xFF2C3440),
onInverseSurface: Color(0xFFF4F6F8),
inversePrimary: Color(0xFF2ED9A0),
surfaceLowest: Color(0xFFFFFFFF),
surfaceLow: Color(0xFFEDF0F4),
surfaceContainer: Color(0xFFE8EBF0),
surfaceHigh: Color(0xFFE2E6EC),
surfaceHighest: Color(0xFFDDE1E7),
surfaceDim: Color(0xFFE8ECF1),
surfaceBright: Color(0xFFFBFCFE),
);
/// Builds the ColorScheme for [brightness].
///
/// When a [seed] is provided (e.g. a color chosen by the user or a system
/// dynamic color), its tonal values are used for the primary family while
/// the brand surfaces, accent and error colors are preserved.
static ColorScheme scheme(Brightness brightness, [Color? seed]) {
final isDark = brightness == Brightness.dark;
final p = isDark ? _dark : _light;
final base = ColorScheme.fromSeed(
brightness: brightness,
seedColor: seed ?? p.primary,
dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
);
final useBrandPrimary = seed == null;
return base.copyWith(
primary: useBrandPrimary ? p.primary : null,
onPrimary: useBrandPrimary ? p.onPrimary : null,
primaryContainer: useBrandPrimary ? p.primaryContainer : null,
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
surfaceTint: useBrandPrimary ? p.primary : null,
secondary: p.secondary,
onSecondary: p.onSecondary,
secondaryContainer: p.secondaryContainer,
onSecondaryContainer: p.onSecondaryContainer,
tertiary: p.tertiary,
onTertiary: p.onTertiary,
tertiaryContainer: p.tertiaryContainer,
onTertiaryContainer: p.onTertiaryContainer,
error: p.error,
onError: p.onError,
errorContainer: p.errorContainer,
onErrorContainer: p.onErrorContainer,
surface: p.surface,
onSurface: p.onSurface,
onSurfaceVariant: p.onSurfaceVariant,
outline: p.outline,
outlineVariant: p.outlineVariant,
inverseSurface: p.inverseSurface,
onInverseSurface: p.onInverseSurface,
inversePrimary: p.inversePrimary,
surfaceContainerLowest: p.surfaceLowest,
surfaceContainerLow: p.surfaceLow,
surfaceContainer: p.surfaceContainer,
surfaceContainerHigh: p.surfaceHigh,
surfaceContainerHighest: p.surfaceHighest,
surfaceDim: p.surfaceDim,
surfaceBright: p.surfaceBright,
);
}
}
class _Scheme {
const _Scheme({
required this.primary,
required this.onPrimary,
required this.primaryContainer,
required this.onPrimaryContainer,
required this.secondary,
required this.onSecondary,
required this.secondaryContainer,
required this.onSecondaryContainer,
required this.tertiary,
required this.onTertiary,
required this.tertiaryContainer,
required this.onTertiaryContainer,
required this.error,
required this.onError,
required this.errorContainer,
required this.onErrorContainer,
required this.surface,
required this.onSurface,
required this.onSurfaceVariant,
required this.outline,
required this.outlineVariant,
required this.inverseSurface,
required this.onInverseSurface,
required this.inversePrimary,
required this.surfaceLowest,
required this.surfaceLow,
required this.surfaceContainer,
required this.surfaceHigh,
required this.surfaceHighest,
required this.surfaceDim,
required this.surfaceBright,
});
final Color primary;
final Color onPrimary;
final Color primaryContainer;
final Color onPrimaryContainer;
final Color secondary;
final Color onSecondary;
final Color secondaryContainer;
final Color onSecondaryContainer;
final Color tertiary;
final Color onTertiary;
final Color tertiaryContainer;
final Color onTertiaryContainer;
final Color error;
final Color onError;
final Color errorContainer;
final Color onErrorContainer;
final Color surface;
final Color onSurface;
final Color onSurfaceVariant;
final Color outline;
final Color outlineVariant;
final Color inverseSurface;
final Color onInverseSurface;
final Color inversePrimary;
final Color surfaceLowest;
final Color surfaceLow;
final Color surfaceContainer;
final Color surfaceHigh;
final Color surfaceHighest;
final Color surfaceDim;
final Color surfaceBright;
}

147
lib/config/themes.dart Normal file
View File

@@ -0,0 +1,147 @@
// 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/tatu_palette.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
abstract class FluffyThemes {
static const double columnWidth = 380.0;
static const double maxTimelineWidth = columnWidth * 2;
static const double navRailWidth = 80.0;
static bool isColumnModeByWidth(double width) =>
width > columnWidth * 2 + navRailWidth;
static bool isColumnMode(BuildContext context) =>
isColumnModeByWidth(MediaQuery.sizeOf(context).width);
static bool isThreeColumnMode(BuildContext context) =>
MediaQuery.sizeOf(context).width > FluffyThemes.columnWidth * 3.5;
static LinearGradient backgroundGradient(BuildContext context, int alpha) {
final colorScheme = Theme.of(context).colorScheme;
return LinearGradient(
begin: Alignment.topCenter,
colors: [
colorScheme.primaryContainer.withAlpha(alpha),
colorScheme.secondaryContainer.withAlpha(alpha),
colorScheme.tertiaryContainer.withAlpha(alpha),
colorScheme.primaryContainer.withAlpha(alpha),
],
);
}
static const Duration animationDuration = Duration(milliseconds: 250);
static const Curve animationCurve = Curves.easeInOut;
static ThemeData buildTheme(
BuildContext context,
Brightness brightness, [
Color? seed,
]) {
final colorScheme = TatuPalette.scheme(brightness, seed);
final isColumnMode = FluffyThemes.isColumnMode(context);
final dividerColor = brightness == Brightness.dark
? colorScheme.surfaceContainerHighest
: colorScheme.surfaceContainer;
return ThemeData(
visualDensity: VisualDensity.standard,
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
dividerColor: dividerColor,
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
iconColor: colorScheme.onSurface,
disabledIconColor: colorScheme.onSurface,
),
),
textSelectionTheme: TextSelectionThemeData(
selectionColor: colorScheme.onSurface.withAlpha(128),
selectionHandleColor: colorScheme.secondary,
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
contentPadding: const EdgeInsets.all(12),
),
chipTheme: ChipThemeData(
showCheckmark: false,
backgroundColor: colorScheme.surfaceContainer,
side: BorderSide.none,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
),
appBarTheme: AppBarTheme(
toolbarHeight: isColumnMode ? 72 : 56,
surfaceTintColor: isColumnMode ? colorScheme.surface : null,
backgroundColor: isColumnMode ? colorScheme.surface : null,
actionsPadding: isColumnMode
? const EdgeInsets.symmetric(horizontal: 16.0)
: null,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness.reversed,
statusBarBrightness: brightness,
systemNavigationBarIconBrightness: brightness.reversed,
systemNavigationBarColor: colorScheme.surface,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
side: BorderSide(width: 1, color: colorScheme.primary),
shape: RoundedRectangleBorder(
side: BorderSide(color: colorScheme.primary),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
strokeCap: StrokeCap.round,
color: colorScheme.primary,
refreshBackgroundColor: colorScheme.primaryContainer,
),
snackBarTheme: isColumnMode
? const SnackBarThemeData(
showCloseIcon: true,
behavior: SnackBarBehavior.floating,
width: FluffyThemes.columnWidth * 1.5,
)
: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
elevation: 0,
padding: const EdgeInsets.all(16),
textStyle: const TextStyle(fontSize: 16),
),
),
);
}
}
extension on Brightness {
Brightness get reversed =>
this == Brightness.dark ? Brightness.light : Brightness.dark;
}
extension BubbleColorTheme on ThemeData {
Color get bubbleColor => brightness == Brightness.light
? colorScheme.primary
: colorScheme.primaryContainer;
Color get onBubbleColor => brightness == Brightness.light
? colorScheme.onPrimary
: colorScheme.onPrimaryContainer;
Color get secondaryBubbleColor => colorScheme.surfaceContainer;
}