Compare commits
6 Commits
c01372353c
...
3f681feb26
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f681feb26 | |||
| a31a88f87a | |||
| 9382a1f946 | |||
| 6f32eb8972 | |||
| 058926cf6f | |||
| 7534ecf7d1 |
@@ -59,6 +59,11 @@ enum AppSettings<T> {
|
|||||||
),
|
),
|
||||||
// colorSchemeSeed stored as ARGB int
|
// colorSchemeSeed stored as ARGB int
|
||||||
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084),
|
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084),
|
||||||
|
|
||||||
|
/// When false the chosen seed color is used verbatim as the theme primary
|
||||||
|
/// color. When true the Material 3 tonal palette is used instead, which
|
||||||
|
/// softens it.
|
||||||
|
softenThemeColor<bool>('chat.fluffy.soften_theme_color', false),
|
||||||
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
|
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
|
||||||
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
|
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
|
||||||
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),
|
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),
|
||||||
|
|||||||
@@ -97,7 +97,14 @@ abstract class TatuPalette {
|
|||||||
/// When a [seed] is provided (e.g. a color chosen by the user or a system
|
/// 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
|
/// dynamic color), its tonal values are used for the primary family while
|
||||||
/// the brand surfaces, accent and error colors are preserved.
|
/// the brand surfaces, accent and error colors are preserved.
|
||||||
static ColorScheme scheme(Brightness brightness, [Color? seed]) {
|
///
|
||||||
|
/// Unless [softenPrimary] is set, the seed is used verbatim as the primary
|
||||||
|
/// color, because the Material 3 tonal palette noticeably dulls it down.
|
||||||
|
static ColorScheme scheme(
|
||||||
|
Brightness brightness, [
|
||||||
|
Color? seed,
|
||||||
|
bool softenPrimary = false,
|
||||||
|
]) {
|
||||||
final isDark = brightness == Brightness.dark;
|
final isDark = brightness == Brightness.dark;
|
||||||
final p = isDark ? _dark : _light;
|
final p = isDark ? _dark : _light;
|
||||||
final base = ColorScheme.fromSeed(
|
final base = ColorScheme.fromSeed(
|
||||||
@@ -106,12 +113,17 @@ abstract class TatuPalette {
|
|||||||
dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
|
dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
|
||||||
);
|
);
|
||||||
final useBrandPrimary = seed == null;
|
final useBrandPrimary = seed == null;
|
||||||
|
// Only force the exact color when the caller did not ask for the soft
|
||||||
|
// Material 3 palette. `null` here means "keep the value from [base]".
|
||||||
|
final exactPrimary = !useBrandPrimary && !softenPrimary ? seed : null;
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
primary: useBrandPrimary ? p.primary : null,
|
primary: useBrandPrimary ? p.primary : exactPrimary,
|
||||||
onPrimary: useBrandPrimary ? p.onPrimary : null,
|
onPrimary: useBrandPrimary
|
||||||
|
? p.onPrimary
|
||||||
|
: (exactPrimary == null ? null : _contrastOn(exactPrimary)),
|
||||||
primaryContainer: useBrandPrimary ? p.primaryContainer : null,
|
primaryContainer: useBrandPrimary ? p.primaryContainer : null,
|
||||||
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
|
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
|
||||||
surfaceTint: useBrandPrimary ? p.primary : null,
|
surfaceTint: useBrandPrimary ? p.primary : exactPrimary,
|
||||||
secondary: p.secondary,
|
secondary: p.secondary,
|
||||||
onSecondary: p.onSecondary,
|
onSecondary: p.onSecondary,
|
||||||
secondaryContainer: p.secondaryContainer,
|
secondaryContainer: p.secondaryContainer,
|
||||||
@@ -141,6 +153,12 @@ abstract class TatuPalette {
|
|||||||
surfaceBright: p.surfaceBright,
|
surfaceBright: p.surfaceBright,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picks the readable foreground for an arbitrary background color.
|
||||||
|
static Color _contrastOn(Color background) =>
|
||||||
|
ThemeData.estimateBrightnessForColor(background) == Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Scheme {
|
class _Scheme {
|
||||||
@@ -209,4 +227,4 @@ class _Scheme {
|
|||||||
final Color surfaceHighest;
|
final Color surfaceHighest;
|
||||||
final Color surfaceDim;
|
final Color surfaceDim;
|
||||||
final Color surfaceBright;
|
final Color surfaceBright;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ abstract class FluffyThemes {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
Brightness brightness, [
|
Brightness brightness, [
|
||||||
Color? seed,
|
Color? seed,
|
||||||
|
bool softenPrimary = false,
|
||||||
]) {
|
]) {
|
||||||
final colorScheme = TatuPalette.scheme(brightness, seed);
|
final colorScheme = TatuPalette.scheme(brightness, seed, softenPrimary);
|
||||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||||
final dividerColor = brightness == Brightness.dark
|
final dividerColor = brightness == Brightness.dark
|
||||||
? colorScheme.surfaceContainerHighest
|
? colorScheme.surfaceContainerHighest
|
||||||
|
|||||||
@@ -3029,5 +3029,15 @@
|
|||||||
"@uninstallAppConfirm": {
|
"@uninstallAppConfirm": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
|
},
|
||||||
|
"softenThemeColor": "Soften theme color",
|
||||||
|
"@softenThemeColor": {
|
||||||
|
"type": "String",
|
||||||
|
"placeholders": {}
|
||||||
|
},
|
||||||
|
"softenThemeColorSubtitle": "Use a soft Material 3 palette derived from the selected color instead of the exact color.",
|
||||||
|
"@softenThemeColorSubtitle": {
|
||||||
|
"type": "String",
|
||||||
|
"placeholders": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2043,5 +2043,7 @@
|
|||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"areYouSureDeletePost": "Eliminar"
|
"areYouSureDeletePost": "Eliminar",
|
||||||
|
"softenThemeColor": "Suavizar cor do tema",
|
||||||
|
"softenThemeColorSubtitle": "Usa uma paleta suave do Material 3 derivada da cor escolhida em vez da cor exata."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ class ChatListController extends State<ChatList>
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
var action = await showModalActionPopup<ChatContextAction>(
|
final action = await showModalActionPopup<ChatContextAction>(
|
||||||
context: posContext,
|
context: posContext,
|
||||||
cancelLabel: l10n.cancel,
|
cancelLabel: l10n.cancel,
|
||||||
actions: [
|
actions: [
|
||||||
@@ -564,36 +564,6 @@ class ChatListController extends State<ChatList>
|
|||||||
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
AdaptiveModalAction(
|
|
||||||
value: ChatContextAction.leave,
|
|
||||||
label: room.membership == Membership.invite
|
|
||||||
? l10n.delete
|
|
||||||
: l10n.leave,
|
|
||||||
icon: const Icon(Icons.delete_outlined),
|
|
||||||
isDestructive: true,
|
|
||||||
),
|
|
||||||
if (room.membership == Membership.invite)
|
|
||||||
AdaptiveModalAction(
|
|
||||||
value: ChatContextAction.block,
|
|
||||||
label: l10n.block,
|
|
||||||
icon: const Icon(Icons.block_outlined),
|
|
||||||
isDestructive: true,
|
|
||||||
),
|
|
||||||
if (room.membership == Membership.join)
|
|
||||||
AdaptiveModalAction(
|
|
||||||
value: ChatContextAction.showMore,
|
|
||||||
label: l10n.more,
|
|
||||||
icon: Icon(Icons.adaptive.more_outlined),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
if (!posContext.mounted || !mounted) return;
|
|
||||||
if (action == ChatContextAction.showMore) {
|
|
||||||
action = await showModalActionPopup<ChatContextAction>(
|
|
||||||
context: posContext,
|
|
||||||
cancelLabel: l10n.cancel,
|
|
||||||
actions: [
|
|
||||||
if (!room.isFavourite)
|
if (!room.isFavourite)
|
||||||
AdaptiveModalAction(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.lowPriority,
|
value: ChatContextAction.lowPriority,
|
||||||
@@ -625,8 +595,24 @@ class ChatListController extends State<ChatList>
|
|||||||
icon: const Icon(Icons.group_work_outlined),
|
icon: const Icon(Icons.group_work_outlined),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
AdaptiveModalAction(
|
||||||
}
|
value: ChatContextAction.leave,
|
||||||
|
label: room.membership == Membership.invite
|
||||||
|
? l10n.delete
|
||||||
|
: l10n.leave,
|
||||||
|
icon: const Icon(Icons.delete_outlined),
|
||||||
|
isDestructive: true,
|
||||||
|
),
|
||||||
|
if (room.membership == Membership.invite)
|
||||||
|
AdaptiveModalAction(
|
||||||
|
value: ChatContextAction.block,
|
||||||
|
label: l10n.block,
|
||||||
|
icon: const Icon(Icons.block_outlined),
|
||||||
|
isDestructive: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!posContext.mounted || !mounted) return;
|
||||||
|
|
||||||
if (action == null) return;
|
if (action == null) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -753,8 +739,6 @@ class ChatListController extends State<ChatList>
|
|||||||
future: () => room.removeTag(activeTag!),
|
future: () => room.removeTag(activeTag!),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case ChatContextAction.showMore:
|
|
||||||
throw ('Should not be handled!');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,5 +1005,4 @@ enum ChatContextAction {
|
|||||||
leave,
|
leave,
|
||||||
addToSpace,
|
addToSpace,
|
||||||
block,
|
block,
|
||||||
showMore,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,57 @@ class ImageViewer extends StatefulWidget {
|
|||||||
class ImageViewerController extends State<ImageViewer> {
|
class ImageViewerController extends State<ImageViewer> {
|
||||||
final FocusNode focusNode = FocusNode();
|
final FocusNode focusNode = FocusNode();
|
||||||
|
|
||||||
|
/// One [TransformationController] per image, so the zoom of one page never
|
||||||
|
/// leaks into its neighbours.
|
||||||
|
final Map<String, TransformationController> _transformations = {};
|
||||||
|
|
||||||
|
int _pointerCount = 0;
|
||||||
|
bool _isZoomed = false;
|
||||||
|
|
||||||
|
/// True while an image is zoomed in, so panning moves the image instead of
|
||||||
|
/// switching to the next one.
|
||||||
|
bool get isZoomed => _isZoomed;
|
||||||
|
|
||||||
|
/// The [PageView] must not compete for the gesture while pinching or while
|
||||||
|
/// an image is zoomed, otherwise the swipe recognizer wins the gesture arena
|
||||||
|
/// and pinch-to-zoom never happens.
|
||||||
|
bool get canSwipeToNextImage => _pointerCount < 2 && !_isZoomed;
|
||||||
|
|
||||||
|
TransformationController transformationControllerFor(Event event) =>
|
||||||
|
_transformations.putIfAbsent(event.eventId, () {
|
||||||
|
final controller = TransformationController();
|
||||||
|
controller.addListener(_onTransformChanged);
|
||||||
|
return controller;
|
||||||
|
});
|
||||||
|
|
||||||
|
void onPointerDown() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _pointerCount++);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPointerUp() {
|
||||||
|
if (!mounted || _pointerCount == 0) return;
|
||||||
|
setState(() => _pointerCount--);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTransformChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final zoomed = _transformations.values.any(
|
||||||
|
(controller) => controller.value.getMaxScaleOnAxis() > 1.01,
|
||||||
|
);
|
||||||
|
if (zoomed == _isZoomed) return;
|
||||||
|
setState(() => _isZoomed = zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the zoom of every image, e.g. after moving to another one.
|
||||||
|
void resetZooms() {
|
||||||
|
for (final controller in _transformations.values) {
|
||||||
|
if (!controller.value.isIdentity()) {
|
||||||
|
controller.value = Matrix4.identity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -72,6 +123,8 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> prevImage() async {
|
Future<void> prevImage() async {
|
||||||
|
if (!canGoBack) return;
|
||||||
|
resetZooms();
|
||||||
await pageController.previousPage(
|
await pageController.previousPage(
|
||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
curve: FluffyThemes.animationCurve,
|
||||||
@@ -81,6 +134,8 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextImage() async {
|
Future<void> nextImage() async {
|
||||||
|
if (!canGoNext) return;
|
||||||
|
resetZooms();
|
||||||
await pageController.nextPage(
|
await pageController.nextPage(
|
||||||
duration: FluffyThemes.animationDuration,
|
duration: FluffyThemes.animationDuration,
|
||||||
curve: FluffyThemes.animationCurve,
|
curve: FluffyThemes.animationCurve,
|
||||||
@@ -112,23 +167,47 @@ class ImageViewerController extends State<ImageViewer> {
|
|||||||
|
|
||||||
static const maxScaleFactor = 1.5;
|
static const maxScaleFactor = 1.5;
|
||||||
|
|
||||||
|
/// Closes the viewer.
|
||||||
|
///
|
||||||
|
/// Always targets the root navigator, because the viewer is opened with
|
||||||
|
/// `showDialog` on the root navigator while the chat room lives in a nested
|
||||||
|
/// navigator of the go_router shell. Using a relative navigator here would
|
||||||
|
/// either pop nothing or pop the wrong route.
|
||||||
|
void closeAction() => Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
|
||||||
/// Go back if user swiped it away
|
/// Go back if user swiped it away
|
||||||
void onInteractionEnds(ScaleEndDetails endDetails) {
|
void onInteractionEnds(ScaleEndDetails endDetails) {
|
||||||
if (PlatformInfos.usesTouchscreen == false) {
|
if (PlatformInfos.usesTouchscreen == false) {
|
||||||
|
if (isZoomed) return;
|
||||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||||
MediaQuery.sizeOf(context).height * maxScaleFactor) {
|
MediaQuery.sizeOf(context).height * maxScaleFactor) {
|
||||||
Navigator.of(context, rootNavigator: false).pop();
|
closeAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final controller in _transformations.values) {
|
||||||
|
controller.removeListener(_onTransformChanged);
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
_transformations.clear();
|
||||||
focusNode.dispose();
|
focusNode.dispose();
|
||||||
pageController.dispose();
|
pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ImageViewerView(this);
|
Widget build(BuildContext context) => PopScope<Object?>(
|
||||||
|
// Claim the system back button (and predictive back gesture) for the
|
||||||
|
// viewer. Without this, the request can reach go_router instead, which
|
||||||
|
// pops the chat room out of the shell and leaves the image on screen.
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (didPop) return;
|
||||||
|
closeAction();
|
||||||
|
},
|
||||||
|
child: ImageViewerView(this),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
onTap: controller.closeAction,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black.withAlpha(128),
|
backgroundColor: Colors.black.withAlpha(128),
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
@@ -34,7 +34,7 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
style: iconButtonStyle,
|
style: iconButtonStyle,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: controller.closeAction,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
tooltip: L10n.of(context).close,
|
tooltip: L10n.of(context).close,
|
||||||
),
|
),
|
||||||
@@ -78,50 +78,60 @@ class ImageViewerView extends StatelessWidget {
|
|||||||
KeyboardListener(
|
KeyboardListener(
|
||||||
focusNode: controller.focusNode,
|
focusNode: controller.focusNode,
|
||||||
onKeyEvent: controller.onKeyEvent,
|
onKeyEvent: controller.onKeyEvent,
|
||||||
child: PageView.builder(
|
child: Listener(
|
||||||
scrollDirection: Axis.vertical,
|
onPointerDown: (_) => controller.onPointerDown(),
|
||||||
controller: controller.pageController,
|
onPointerUp: (_) => controller.onPointerUp(),
|
||||||
itemCount: controller.allEvents.length,
|
onPointerCancel: (_) => controller.onPointerUp(),
|
||||||
itemBuilder: (context, i) {
|
child: PageView.builder(
|
||||||
final event = controller.allEvents[i];
|
scrollDirection: Axis.vertical,
|
||||||
switch (event.messageType) {
|
controller: controller.pageController,
|
||||||
case MessageTypes.Video:
|
physics: controller.canSwipeToNextImage
|
||||||
return Padding(
|
? const PageScrollPhysics()
|
||||||
padding: const EdgeInsets.only(top: 52.0),
|
: const NeverScrollableScrollPhysics(),
|
||||||
child: Center(
|
itemCount: controller.allEvents.length,
|
||||||
child: GestureDetector(
|
itemBuilder: (context, i) {
|
||||||
// Ignore taps to not go back here:
|
final event = controller.allEvents[i];
|
||||||
onTap: () {},
|
switch (event.messageType) {
|
||||||
child: EventVideoPlayer(event),
|
case MessageTypes.Video:
|
||||||
),
|
return Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 52.0),
|
||||||
);
|
child: Center(
|
||||||
case MessageTypes.Image:
|
|
||||||
case MessageTypes.Sticker:
|
|
||||||
default:
|
|
||||||
return InteractiveViewer(
|
|
||||||
minScale: 1.0,
|
|
||||||
maxScale: 10.0,
|
|
||||||
onInteractionEnd: controller.onInteractionEnds,
|
|
||||||
child: Center(
|
|
||||||
child: Hero(
|
|
||||||
tag: event.eventId,
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// Ignore taps to not go back here:
|
// Ignore taps to not go back here:
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: MxcImage(
|
child: EventVideoPlayer(event),
|
||||||
key: ValueKey(event.eventId),
|
),
|
||||||
event: event,
|
),
|
||||||
fit: BoxFit.contain,
|
);
|
||||||
isThumbnail: false,
|
case MessageTypes.Image:
|
||||||
animated: true,
|
case MessageTypes.Sticker:
|
||||||
|
default:
|
||||||
|
return InteractiveViewer(
|
||||||
|
transformationController: controller
|
||||||
|
.transformationControllerFor(event),
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 10.0,
|
||||||
|
onInteractionEnd: controller.onInteractionEnds,
|
||||||
|
child: Center(
|
||||||
|
child: Hero(
|
||||||
|
tag: event.eventId,
|
||||||
|
child: GestureDetector(
|
||||||
|
// Ignore taps to not go back here:
|
||||||
|
onTap: () {},
|
||||||
|
child: MxcImage(
|
||||||
|
key: ValueKey(event.eventId),
|
||||||
|
event: event,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
isThumbnail: false,
|
||||||
|
animated: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hovered)
|
if (hovered)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:tatchat/utils/file_selector.dart';
|
|||||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||||
import 'package:tatchat/widgets/theme_builder.dart';
|
import 'package:tatchat/widgets/theme_builder.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
import '../../widgets/matrix.dart';
|
import '../../widgets/matrix.dart';
|
||||||
import 'settings_style_view.dart';
|
import 'settings_style_view.dart';
|
||||||
@@ -22,11 +23,18 @@ class SettingsStyle extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SettingsStyleController extends State<SettingsStyle> {
|
class SettingsStyleController extends State<SettingsStyle> {
|
||||||
void setChatColor(Color? color) {
|
Future<void> setChatColor(Color? color) async {
|
||||||
|
final client = Matrix.of(context).client;
|
||||||
|
final themeController = ThemeController.of(context);
|
||||||
AppSettings.colorSchemeSeedInt.setItem(
|
AppSettings.colorSchemeSeedInt.setItem(
|
||||||
color?.toARGB32() ?? AppSettings.colorSchemeSeedInt.defaultValue,
|
color?.toARGB32() ?? AppSettings.colorSchemeSeedInt.defaultValue,
|
||||||
);
|
);
|
||||||
ThemeController.of(context).setPrimaryColor(color);
|
await themeController.setPrimaryColor(color);
|
||||||
|
try {
|
||||||
|
await client.setApplicationAccountColor(color?.toARGB32());
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs().e('Unable to save chat color', e, s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setWallpaper() async {
|
Future<void> setWallpaper() async {
|
||||||
@@ -105,16 +113,30 @@ class SettingsStyleController extends State<SettingsStyle> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteChatWallpaper() => showFutureLoadingDialog(
|
void deleteChatWallpaper() {
|
||||||
context: context,
|
final client = Matrix.of(context).client;
|
||||||
future: () => Matrix.of(context).client.setApplicationAccountConfig(
|
showFutureLoadingDialog(
|
||||||
const ApplicationAccountConfig(wallpaperUrl: null, wallpaperBlur: null),
|
context: context,
|
||||||
),
|
future: () => client.setApplicationAccountConfig(
|
||||||
);
|
ApplicationAccountConfig(
|
||||||
|
wallpaperUrl: null,
|
||||||
|
wallpaperBlur: null,
|
||||||
|
primaryColor: client.applicationAccountConfig.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ThemeMode get currentTheme => ThemeController.of(context).themeMode;
|
ThemeMode get currentTheme => ThemeController.of(context).themeMode;
|
||||||
Color? get currentColor => ThemeController.of(context).primaryColor;
|
Color? get currentColor => ThemeController.of(context).primaryColor;
|
||||||
|
|
||||||
|
bool get softenThemeColor => ThemeController.of(context).softenPrimaryColor;
|
||||||
|
|
||||||
|
Future<void> setSoftenThemeColor(bool soften) async {
|
||||||
|
await ThemeController.of(context).setSoftenPrimaryColor(soften);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
void switchTheme(ThemeMode? newTheme) {
|
void switchTheme(ThemeMode? newTheme) {
|
||||||
if (newTheme == null) return;
|
if (newTheme == null) return;
|
||||||
switch (newTheme) {
|
switch (newTheme) {
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ class SettingsStyleView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.palette_outlined),
|
||||||
|
title: Text(L10n.of(context).softenThemeColor),
|
||||||
|
subtitle: Text(L10n.of(context).softenThemeColorSubtitle),
|
||||||
|
value: controller.softenThemeColor,
|
||||||
|
onChanged: controller.setSoftenThemeColor,
|
||||||
|
),
|
||||||
Divider(color: theme.dividerColor),
|
Divider(color: theme.dividerColor),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
@@ -176,24 +183,38 @@ class SettingsStyleView extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (accountConfig.wallpaperUrl != null)
|
Opacity(
|
||||||
Opacity(
|
opacity: accountConfig.wallpaperUrl != null
|
||||||
opacity: controller.wallpaperOpacity,
|
? controller.wallpaperOpacity
|
||||||
child: ImageFiltered(
|
: 0.3,
|
||||||
imageFilter: ImageFilter.blur(
|
child: ImageFiltered(
|
||||||
sigmaX: controller.wallpaperBlur,
|
imageFilter: ImageFilter.blur(
|
||||||
sigmaY: controller.wallpaperBlur,
|
sigmaX: accountConfig.wallpaperUrl != null
|
||||||
),
|
? controller.wallpaperBlur
|
||||||
child: MxcImage(
|
: 0.0,
|
||||||
key: ValueKey(accountConfig.wallpaperUrl),
|
sigmaY: accountConfig.wallpaperUrl != null
|
||||||
uri: accountConfig.wallpaperUrl,
|
? controller.wallpaperBlur
|
||||||
fit: BoxFit.cover,
|
: 0.0,
|
||||||
isThumbnail: true,
|
|
||||||
width: FluffyThemes.columnWidth * 2,
|
|
||||||
height: 212,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: accountConfig.wallpaperUrl != null
|
||||||
|
? MxcImage(
|
||||||
|
key: ValueKey(
|
||||||
|
accountConfig.wallpaperUrl,
|
||||||
|
),
|
||||||
|
uri: accountConfig.wallpaperUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
isThumbnail: true,
|
||||||
|
width: FluffyThemes.columnWidth * 2,
|
||||||
|
height: 212,
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
'assets/logo/background.png',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: FluffyThemes.columnWidth * 2,
|
||||||
|
height: 212,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
@@ -213,12 +234,10 @@ class SettingsStyleView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 12 + 12 + Avatar.defaultSize,
|
left: 12 + 12 + Avatar.defaultSize,
|
||||||
right: 12,
|
right: 12,
|
||||||
top: accountConfig.wallpaperUrl == null
|
top: 12,
|
||||||
? 0
|
|
||||||
: 12,
|
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
),
|
),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
@@ -246,12 +265,10 @@ class SettingsStyleView extends StatelessWidget {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
right: 12,
|
right: 12,
|
||||||
left: 12,
|
left: 12,
|
||||||
top: accountConfig.wallpaperUrl == null
|
top: 12,
|
||||||
? 0
|
|
||||||
: 12,
|
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
|
|||||||
@@ -27,20 +27,53 @@ extension ApplicationAccountConfigExtension on Client {
|
|||||||
wallpaperOpacity:
|
wallpaperOpacity:
|
||||||
config.wallpaperOpacity ?? currentConfig.wallpaperOpacity,
|
config.wallpaperOpacity ?? currentConfig.wallpaperOpacity,
|
||||||
wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur,
|
wallpaperBlur: config.wallpaperBlur ?? currentConfig.wallpaperBlur,
|
||||||
|
primaryColor: config.primaryColor ?? currentConfig.primaryColor,
|
||||||
|
softenThemeColor:
|
||||||
|
config.softenThemeColor ?? currentConfig.softenThemeColor,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setApplicationAccountColor(int? primaryColor) {
|
||||||
|
final currentConfig = applicationAccountConfig;
|
||||||
|
return setApplicationAccountConfig(
|
||||||
|
ApplicationAccountConfig(
|
||||||
|
wallpaperUrl: currentConfig.wallpaperUrl,
|
||||||
|
wallpaperOpacity: currentConfig.wallpaperOpacity,
|
||||||
|
wallpaperBlur: currentConfig.wallpaperBlur,
|
||||||
|
primaryColor: primaryColor,
|
||||||
|
softenThemeColor: currentConfig.softenThemeColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setApplicationAccountSoftenThemeColor(bool soften) {
|
||||||
|
final currentConfig = applicationAccountConfig;
|
||||||
|
return setApplicationAccountConfig(
|
||||||
|
ApplicationAccountConfig(
|
||||||
|
wallpaperUrl: currentConfig.wallpaperUrl,
|
||||||
|
wallpaperOpacity: currentConfig.wallpaperOpacity,
|
||||||
|
wallpaperBlur: currentConfig.wallpaperBlur,
|
||||||
|
primaryColor: currentConfig.primaryColor,
|
||||||
|
softenThemeColor: soften,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApplicationAccountConfig {
|
class ApplicationAccountConfig {
|
||||||
final Uri? wallpaperUrl;
|
final Uri? wallpaperUrl;
|
||||||
final double? wallpaperOpacity;
|
final double? wallpaperOpacity;
|
||||||
final double? wallpaperBlur;
|
final double? wallpaperBlur;
|
||||||
|
final int? primaryColor;
|
||||||
|
final bool? softenThemeColor;
|
||||||
|
|
||||||
const ApplicationAccountConfig({
|
const ApplicationAccountConfig({
|
||||||
this.wallpaperUrl,
|
this.wallpaperUrl,
|
||||||
this.wallpaperOpacity,
|
this.wallpaperOpacity,
|
||||||
this.wallpaperBlur,
|
this.wallpaperBlur,
|
||||||
|
this.primaryColor,
|
||||||
|
this.softenThemeColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
static double _sanitizedOpacity(double? opacity) {
|
static double _sanitizedOpacity(double? opacity) {
|
||||||
@@ -58,11 +91,15 @@ class ApplicationAccountConfig {
|
|||||||
json.tryGet<double>('wallpaper_opacity'),
|
json.tryGet<double>('wallpaper_opacity'),
|
||||||
),
|
),
|
||||||
wallpaperBlur: json.tryGet<double>('wallpaper_blur'),
|
wallpaperBlur: json.tryGet<double>('wallpaper_blur'),
|
||||||
|
primaryColor: json.tryGet<int>('primary_color'),
|
||||||
|
softenThemeColor: json.tryGet<bool>('soften_theme_color'),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'wallpaper_url': wallpaperUrl?.toString(),
|
'wallpaper_url': wallpaperUrl?.toString(),
|
||||||
'wallpaper_opacity': wallpaperOpacity,
|
'wallpaper_opacity': wallpaperOpacity,
|
||||||
'wallpaper_blur': wallpaperBlur,
|
'wallpaper_blur': wallpaperBlur,
|
||||||
|
'primary_color': primaryColor,
|
||||||
|
'soften_theme_color': softenThemeColor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,13 @@ class Matrix extends StatefulWidget {
|
|||||||
|
|
||||||
final SharedPreferences store;
|
final SharedPreferences store;
|
||||||
|
|
||||||
|
final ValueChanged<Client?>? onActiveClientChanged;
|
||||||
|
|
||||||
const Matrix({
|
const Matrix({
|
||||||
this.child,
|
this.child,
|
||||||
required this.clients,
|
required this.clients,
|
||||||
required this.store,
|
required this.store,
|
||||||
|
this.onActiveClientChanged,
|
||||||
this.queryParameters,
|
this.queryParameters,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
@@ -89,11 +92,21 @@ class MatrixState extends State<Matrix> {
|
|||||||
final i = widget.clients.indexWhere((c) => c == cl);
|
final i = widget.clients.indexWhere((c) => c == cl);
|
||||||
if (i != -1) {
|
if (i != -1) {
|
||||||
_activeClient = i;
|
_activeClient = i;
|
||||||
|
_notifyActiveClientChanged();
|
||||||
} else {
|
} else {
|
||||||
Logs().w('Tried to set an unknown client ${cl!.userID} as active');
|
Logs().w('Tried to set an unknown client ${cl!.userID} as active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _notifyActiveClientChanged() {
|
||||||
|
final callback = widget.onActiveClientChanged;
|
||||||
|
if (callback == null) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
callback(widget.clients.isEmpty ? null : client);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
List<Client?>? get currentBundle {
|
List<Client?>? get currentBundle {
|
||||||
if (!hasComplexBundles) {
|
if (!hasComplexBundles) {
|
||||||
return List.from(widget.clients);
|
return List.from(widget.clients);
|
||||||
@@ -256,19 +269,27 @@ class MatrixState extends State<Matrix> {
|
|||||||
.where((state) => state == LoginState.loggedOut)
|
.where((state) => state == LoginState.loggedOut)
|
||||||
.listen((_) {
|
.listen((_) {
|
||||||
final loggedInWithMultipleClients = widget.clients.length > 1;
|
final loggedInWithMultipleClients = widget.clients.length > 1;
|
||||||
|
final activeClient = client;
|
||||||
|
final removedIndex = widget.clients.indexOf(c);
|
||||||
|
final wasActive = removedIndex != -1 && activeClient == c;
|
||||||
|
|
||||||
_cancelSubs(c.clientName);
|
_cancelSubs(c.clientName);
|
||||||
widget.clients.remove(c);
|
widget.clients.remove(c);
|
||||||
ClientManager.removeClientNameFromStore(c.clientName, store);
|
ClientManager.removeClientNameFromStore(c.clientName, store);
|
||||||
InitWithRestoreExtension.deleteSessionBackup(name);
|
InitWithRestoreExtension.deleteSessionBackup(name);
|
||||||
|
if (removedIndex != -1 && _activeClient > removedIndex) {
|
||||||
|
_activeClient--;
|
||||||
|
}
|
||||||
|
if (wasActive) {
|
||||||
|
_activeClient = -1;
|
||||||
|
}
|
||||||
|
if (removedIndex != -1) {
|
||||||
|
_notifyActiveClientChanged();
|
||||||
|
}
|
||||||
|
|
||||||
if (loggedInWithMultipleClients) {
|
if (loggedInWithMultipleClients) {
|
||||||
final snackbarContext =
|
final snackbarContext =
|
||||||
TatuChatApp
|
TatuChatApp.router.routerDelegate.navigatorKey.currentContext ??
|
||||||
.router
|
|
||||||
.routerDelegate
|
|
||||||
.navigatorKey
|
|
||||||
.currentContext ??
|
|
||||||
context;
|
context;
|
||||||
|
|
||||||
if (!snackbarContext.mounted) return;
|
if (!snackbarContext.mounted) return;
|
||||||
@@ -349,6 +370,7 @@ class MatrixState extends State<Matrix> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_notifyActiveClientChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
|||||||
@@ -13,47 +13,61 @@ class MxcImageViewer extends StatelessWidget {
|
|||||||
|
|
||||||
const MxcImageViewer(this.mxContent, {super.key});
|
const MxcImageViewer(this.mxContent, {super.key});
|
||||||
|
|
||||||
|
/// The viewer is opened with `showDialog` on the root navigator, so closing
|
||||||
|
/// it must always target the root navigator.
|
||||||
|
static void _close(BuildContext context) =>
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final iconButtonStyle = IconButton.styleFrom(
|
final iconButtonStyle = IconButton.styleFrom(
|
||||||
backgroundColor: Colors.black.withAlpha(200),
|
backgroundColor: Colors.black.withAlpha(200),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return PopScope<Object?>(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
// Claim the system back button for the viewer so that it is dismissed
|
||||||
child: Scaffold(
|
// instead of letting the router navigate away underneath it.
|
||||||
backgroundColor: Colors.black.withAlpha(128),
|
canPop: false,
|
||||||
extendBodyBehindAppBar: true,
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
appBar: AppBar(
|
if (didPop) return;
|
||||||
elevation: 0,
|
_close(context);
|
||||||
leading: IconButton(
|
},
|
||||||
style: iconButtonStyle,
|
child: GestureDetector(
|
||||||
icon: const Icon(Icons.close),
|
onTap: () => _close(context),
|
||||||
onPressed: Navigator.of(context).pop,
|
child: Scaffold(
|
||||||
color: Colors.white,
|
backgroundColor: Colors.black.withAlpha(128),
|
||||||
tooltip: L10n.of(context).close,
|
extendBodyBehindAppBar: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
style: iconButtonStyle,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => _close(context),
|
||||||
|
color: Colors.white,
|
||||||
|
tooltip: L10n.of(context).close,
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.transparent,
|
body: InteractiveViewer(
|
||||||
),
|
minScale: 1.0,
|
||||||
body: InteractiveViewer(
|
maxScale: 10.0,
|
||||||
minScale: 1.0,
|
onInteractionEnd: (endDetails) {
|
||||||
maxScale: 10.0,
|
if (endDetails.velocity.pixelsPerSecond.dy >
|
||||||
onInteractionEnd: (endDetails) {
|
MediaQuery.sizeOf(context).height * 1.5) {
|
||||||
if (endDetails.velocity.pixelsPerSecond.dy >
|
_close(context);
|
||||||
MediaQuery.sizeOf(context).height * 1.5) {
|
}
|
||||||
Navigator.of(context, rootNavigator: false).pop();
|
},
|
||||||
}
|
child: Center(
|
||||||
},
|
child: GestureDetector(
|
||||||
child: Center(
|
// Ignore taps to not go back here:
|
||||||
child: GestureDetector(
|
onTap: () {},
|
||||||
// Ignore taps to not go back here:
|
child: MxcImage(
|
||||||
onTap: () {},
|
key: ValueKey(mxContent.toString()),
|
||||||
child: MxcImage(
|
uri: mxContent,
|
||||||
key: ValueKey(mxContent.toString()),
|
fit: BoxFit.contain,
|
||||||
uri: mxContent,
|
isThumbnail: false,
|
||||||
fit: BoxFit.contain,
|
animated: true,
|
||||||
isThumbnail: false,
|
),
|
||||||
animated: true,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,38 +65,48 @@ class TatuChatApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ThemeBuilder(
|
return ThemeBuilder(
|
||||||
builder: (context, themeMode, primaryColor) => MaterialApp.router(
|
builder: (context, themeMode, primaryColor, themeController) =>
|
||||||
title: AppSettings.applicationName.value,
|
MaterialApp.router(
|
||||||
themeMode: themeMode,
|
title: AppSettings.applicationName.value,
|
||||||
theme: FluffyThemes.buildTheme(context, Brightness.light, primaryColor),
|
themeMode: themeMode,
|
||||||
darkTheme: FluffyThemes.buildTheme(
|
theme: FluffyThemes.buildTheme(
|
||||||
context,
|
context,
|
||||||
Brightness.dark,
|
Brightness.light,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
),
|
themeController.softenPrimaryColor,
|
||||||
scrollBehavior: CustomScrollBehavior(),
|
),
|
||||||
localizationsDelegates: [
|
darkTheme: FluffyThemes.buildTheme(
|
||||||
...L10n.localizationsDelegates,
|
context,
|
||||||
...GlobalMaterialLocalizations.delegates,
|
Brightness.dark,
|
||||||
...GlobalCupertinoLocalizations.delegates,
|
primaryColor,
|
||||||
],
|
themeController.softenPrimaryColor,
|
||||||
supportedLocales: L10n.supportedLocales,
|
),
|
||||||
routerConfig: router,
|
scrollBehavior: CustomScrollBehavior(),
|
||||||
builder: (context, child) => AppLockWidget(
|
localizationsDelegates: [
|
||||||
pincode: appLockSettings.pincode,
|
...L10n.localizationsDelegates,
|
||||||
useBiometrics: appLockSettings.useBiometrics,
|
...GlobalMaterialLocalizations.delegates,
|
||||||
isLoggedIn: clients.any((client) => client.isLogged()),
|
...GlobalCupertinoLocalizations.delegates,
|
||||||
// Need a navigator above the Matrix widget for
|
],
|
||||||
// displaying dialogs
|
supportedLocales: L10n.supportedLocales,
|
||||||
child: Matrix(
|
routerConfig: router,
|
||||||
clients: clients,
|
builder: (context, child) => AppLockWidget(
|
||||||
store: store,
|
pincode: appLockSettings.pincode,
|
||||||
child: CallOverlay(
|
useBiometrics: appLockSettings.useBiometrics,
|
||||||
child: testWidget ?? child ?? const SizedBox.shrink(),
|
isLoggedIn: clients.any((client) => client.isLogged()),
|
||||||
|
// Need a navigator above the Matrix widget for
|
||||||
|
// displaying dialogs
|
||||||
|
child: Matrix(
|
||||||
|
clients: clients,
|
||||||
|
store: store,
|
||||||
|
onActiveClientChanged: (client) {
|
||||||
|
themeController.setAccountClient(client);
|
||||||
|
},
|
||||||
|
child: CallOverlay(
|
||||||
|
child: testWidget ?? child ?? const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import 'package:tatchat/config/setting_keys.dart';
|
||||||
|
import 'package:tatchat/utils/account_config.dart';
|
||||||
import 'package:tatchat/utils/color_value.dart';
|
import 'package:tatchat/utils/color_value.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -15,6 +20,7 @@ class ThemeBuilder extends StatefulWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeMode themeMode,
|
ThemeMode themeMode,
|
||||||
Color? primaryColor,
|
Color? primaryColor,
|
||||||
|
ThemeController controller,
|
||||||
)
|
)
|
||||||
builder;
|
builder;
|
||||||
|
|
||||||
@@ -36,14 +42,42 @@ class ThemeController extends State<ThemeBuilder> {
|
|||||||
SharedPreferences? _sharedPreferences;
|
SharedPreferences? _sharedPreferences;
|
||||||
ThemeMode? _themeMode;
|
ThemeMode? _themeMode;
|
||||||
Color? _primaryColor;
|
Color? _primaryColor;
|
||||||
|
Color? _localPrimaryColor;
|
||||||
|
Color? _accountPrimaryColor;
|
||||||
|
bool _softenPrimaryColor = false;
|
||||||
|
bool _localSoftenPrimaryColor = false;
|
||||||
|
bool? _accountSoftenPrimaryColor;
|
||||||
|
Client? _accountClient;
|
||||||
|
StreamSubscription<SyncUpdate>? _accountClientSubscription;
|
||||||
|
int _accountClientGeneration = 0;
|
||||||
|
|
||||||
ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
|
ThemeMode get themeMode => _themeMode ?? ThemeMode.system;
|
||||||
|
|
||||||
Color? get primaryColor => _primaryColor;
|
Color? get primaryColor => _primaryColor;
|
||||||
|
|
||||||
|
bool get softenPrimaryColor => _softenPrimaryColor;
|
||||||
|
|
||||||
static ThemeController of(BuildContext context) =>
|
static ThemeController of(BuildContext context) =>
|
||||||
Provider.of<ThemeController>(context, listen: false);
|
Provider.of<ThemeController>(context, listen: false);
|
||||||
|
|
||||||
|
void _updatePrimaryColor() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final newColor = _accountPrimaryColor ?? _localPrimaryColor;
|
||||||
|
if (newColor == _primaryColor) return;
|
||||||
|
setState(() {
|
||||||
|
_primaryColor = newColor;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSoftenPrimaryColor() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final newValue = _accountSoftenPrimaryColor ?? _localSoftenPrimaryColor;
|
||||||
|
if (newValue == _softenPrimaryColor) return;
|
||||||
|
setState(() {
|
||||||
|
_softenPrimaryColor = newValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadData(_) async {
|
Future<void> _loadData(_) async {
|
||||||
final preferences = _sharedPreferences ??=
|
final preferences = _sharedPreferences ??=
|
||||||
await SharedPreferences.getInstance();
|
await SharedPreferences.getInstance();
|
||||||
@@ -51,12 +85,16 @@ class ThemeController extends State<ThemeBuilder> {
|
|||||||
final rawThemeMode = preferences.getString(widget.themeModeSettingsKey);
|
final rawThemeMode = preferences.getString(widget.themeModeSettingsKey);
|
||||||
final rawColor = preferences.getInt(widget.primaryColorSettingsKey);
|
final rawColor = preferences.getInt(widget.primaryColorSettingsKey);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_themeMode = ThemeMode.values.singleWhereOrNull(
|
_themeMode = ThemeMode.values.singleWhereOrNull(
|
||||||
(value) => value.name == rawThemeMode,
|
(value) => value.name == rawThemeMode,
|
||||||
);
|
);
|
||||||
_primaryColor = rawColor == null ? null : Color(rawColor);
|
_localPrimaryColor = rawColor == null ? null : Color(rawColor);
|
||||||
|
_localSoftenPrimaryColor = AppSettings.softenThemeColor.value;
|
||||||
});
|
});
|
||||||
|
_updatePrimaryColor();
|
||||||
|
_updateSoftenPrimaryColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setThemeMode(ThemeMode newThemeMode) async {
|
Future<void> setThemeMode(ThemeMode newThemeMode) async {
|
||||||
@@ -69,6 +107,12 @@ class ThemeController extends State<ThemeBuilder> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setPrimaryColor(Color? newPrimaryColor) async {
|
Future<void> setPrimaryColor(Color? newPrimaryColor) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_localPrimaryColor = newPrimaryColor;
|
||||||
|
_accountPrimaryColor = newPrimaryColor;
|
||||||
|
_primaryColor = newPrimaryColor;
|
||||||
|
});
|
||||||
final preferences = _sharedPreferences ??=
|
final preferences = _sharedPreferences ??=
|
||||||
await SharedPreferences.getInstance();
|
await SharedPreferences.getInstance();
|
||||||
if (newPrimaryColor == null) {
|
if (newPrimaryColor == null) {
|
||||||
@@ -79,9 +123,78 @@ class ThemeController extends State<ThemeBuilder> {
|
|||||||
newPrimaryColor.hexValue,
|
newPrimaryColor.hexValue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSoftenPrimaryColor(bool soften) async {
|
||||||
|
await AppSettings.softenThemeColor.setItem(soften);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_primaryColor = newPrimaryColor;
|
_localSoftenPrimaryColor = soften;
|
||||||
|
_accountSoftenPrimaryColor = soften;
|
||||||
|
_softenPrimaryColor = soften;
|
||||||
});
|
});
|
||||||
|
final client = _accountClient;
|
||||||
|
if (client == null) return;
|
||||||
|
try {
|
||||||
|
await client.setApplicationAccountSoftenThemeColor(soften);
|
||||||
|
} catch (err) {
|
||||||
|
Logs().e('TatuChat: failed to sync soften theme color', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAccountClient(Client? client) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final generation = ++_accountClientGeneration;
|
||||||
|
_accountClientSubscription?.cancel();
|
||||||
|
_accountClientSubscription = null;
|
||||||
|
_accountClient = client;
|
||||||
|
_accountPrimaryColor = null;
|
||||||
|
_accountSoftenPrimaryColor = null;
|
||||||
|
_updatePrimaryColor();
|
||||||
|
_updateSoftenPrimaryColor();
|
||||||
|
if (client == null) return;
|
||||||
|
|
||||||
|
unawaited(_loadAccountClientConfig(client, generation));
|
||||||
|
_accountClientSubscription = client.onSync.stream
|
||||||
|
.where(
|
||||||
|
(sync) =>
|
||||||
|
sync.accountData?.any(
|
||||||
|
(accountData) =>
|
||||||
|
accountData.type ==
|
||||||
|
ApplicationAccountConfigExtension.accountDataKey,
|
||||||
|
) ??
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.listen((_) => _applyAccountClientConfig(client, generation));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAccountClientConfig(Client client, int generation) async {
|
||||||
|
final accountDataLoading = client.accountDataLoading;
|
||||||
|
if (accountDataLoading != null) {
|
||||||
|
try {
|
||||||
|
await accountDataLoading;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!mounted ||
|
||||||
|
generation != _accountClientGeneration ||
|
||||||
|
!identical(_accountClient, client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_applyAccountClientConfig(client, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyAccountClientConfig(Client client, int generation) {
|
||||||
|
if (!mounted ||
|
||||||
|
generation != _accountClientGeneration ||
|
||||||
|
!identical(_accountClient, client)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final config = client.applicationAccountConfig;
|
||||||
|
final primaryColor = config.primaryColor;
|
||||||
|
_accountPrimaryColor = primaryColor == null ? null : Color(primaryColor);
|
||||||
|
_accountSoftenPrimaryColor = config.softenThemeColor;
|
||||||
|
_updatePrimaryColor();
|
||||||
|
_updateSoftenPrimaryColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -95,9 +208,19 @@ class ThemeController extends State<ThemeBuilder> {
|
|||||||
return Provider(
|
return Provider(
|
||||||
create: (_) => this,
|
create: (_) => this,
|
||||||
child: DynamicColorBuilder(
|
child: DynamicColorBuilder(
|
||||||
builder: (light, _) =>
|
builder: (light, _) => widget.builder(
|
||||||
widget.builder(context, themeMode, primaryColor ?? light?.primary),
|
context,
|
||||||
|
themeMode,
|
||||||
|
primaryColor ?? light?.primary,
|
||||||
|
this,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_accountClientSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/
|
- assets/
|
||||||
|
- assets/logo/background.png
|
||||||
- assets/logo/mini/
|
- assets/logo/mini/
|
||||||
- assets/miniapps/
|
- assets/miniapps/
|
||||||
- assets/sounds/
|
- assets/sounds/
|
||||||
|
|||||||
114
test/account_config_test.dart
Normal file
114
test/account_config_test.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 TatuChat
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tatchat/utils/account_config.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ApplicationAccountConfig', () {
|
||||||
|
test('Parses primary color from account data', () {
|
||||||
|
final config = ApplicationAccountConfig.fromJson({
|
||||||
|
'primary_color': 0xFF6750A4,
|
||||||
|
});
|
||||||
|
expect(config.primaryColor, 0xFF6750A4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ignores primary color of unexpected type', () {
|
||||||
|
expect(
|
||||||
|
ApplicationAccountConfig.fromJson({
|
||||||
|
'primary_color': '4286369748',
|
||||||
|
}).primaryColor,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ApplicationAccountConfig.fromJson({'primary_color': 1.5}).primaryColor,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Defaults primary color to null when absent', () {
|
||||||
|
expect(ApplicationAccountConfig.fromJson({}).primaryColor, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Serializes primary color', () {
|
||||||
|
const config = ApplicationAccountConfig(primaryColor: 0xFF00D084);
|
||||||
|
expect(config.toJson()['primary_color'], 0xFF00D084);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Serializes missing primary color as null', () {
|
||||||
|
const config = ApplicationAccountConfig(wallpaperOpacity: 0.5);
|
||||||
|
expect(config.toJson()['primary_color'], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Round trips through json', () {
|
||||||
|
final config = ApplicationAccountConfig(
|
||||||
|
wallpaperUrl: Uri.parse('mxc://example.invalid/wallpaper'),
|
||||||
|
wallpaperOpacity: 0.3,
|
||||||
|
wallpaperBlur: 4,
|
||||||
|
primaryColor: 0xFF6750A4,
|
||||||
|
softenThemeColor: true,
|
||||||
|
);
|
||||||
|
final parsed = ApplicationAccountConfig.fromJson(config.toJson());
|
||||||
|
expect(parsed.wallpaperUrl, config.wallpaperUrl);
|
||||||
|
expect(parsed.wallpaperOpacity, config.wallpaperOpacity);
|
||||||
|
expect(parsed.wallpaperBlur, config.wallpaperBlur);
|
||||||
|
expect(parsed.primaryColor, config.primaryColor);
|
||||||
|
expect(parsed.softenThemeColor, config.softenThemeColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Keeps existing wallpaper defaults when parsing color only', () {
|
||||||
|
final config = ApplicationAccountConfig.fromJson({
|
||||||
|
'primary_color': 0xFF6750A4,
|
||||||
|
});
|
||||||
|
expect(config.wallpaperUrl, isNull);
|
||||||
|
expect(config.wallpaperOpacity, 1);
|
||||||
|
expect(config.wallpaperBlur, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Parses soften theme color from account data', () {
|
||||||
|
expect(
|
||||||
|
ApplicationAccountConfig.fromJson({
|
||||||
|
'soften_theme_color': true,
|
||||||
|
}).softenThemeColor,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ApplicationAccountConfig.fromJson({
|
||||||
|
'soften_theme_color': false,
|
||||||
|
}).softenThemeColor,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ignores soften theme color of unexpected type', () {
|
||||||
|
expect(
|
||||||
|
ApplicationAccountConfig.fromJson({
|
||||||
|
'soften_theme_color': 'yes',
|
||||||
|
}).softenThemeColor,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Defaults soften theme color to null when absent', () {
|
||||||
|
expect(ApplicationAccountConfig.fromJson({}).softenThemeColor, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Serializes soften theme color', () {
|
||||||
|
const config = ApplicationAccountConfig(softenThemeColor: true);
|
||||||
|
expect(config.toJson()['soften_theme_color'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Serializes missing soften theme color as null', () {
|
||||||
|
const config = ApplicationAccountConfig(primaryColor: 0xFF00D084);
|
||||||
|
expect(config.toJson()['soften_theme_color'], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Round trips soften theme color through json', () {
|
||||||
|
final parsed = ApplicationAccountConfig.fromJson(
|
||||||
|
const ApplicationAccountConfig(softenThemeColor: true).toJson(),
|
||||||
|
);
|
||||||
|
expect(parsed.softenThemeColor, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user