update media view to new mybutton design
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-16 17:40:57 +02:00
parent f22e9086ed
commit d528913e84
6 changed files with 477 additions and 269 deletions

View file

@ -85,9 +85,12 @@ final routerProvider = GoRouter(
),
GoRoute(
path: 'media_viewer',
builder: (context, state) {
pageBuilder: (context, state) {
final group = state.extra! as Group;
return MediaViewerView(group);
return MediaViewerView.buildPage(
key: state.pageKey,
group: group,
);
},
),
GoRoute(

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
enum MyIconButtonVariant {
primary,
secondary,
}
class MyIconButton extends StatefulWidget {
const MyIconButton({
required this.icon,
required this.onPressed,
this.onLongPress,
this.variant = MyIconButtonVariant.primary,
super.key,
});
final Widget icon;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final MyIconButtonVariant variant;
@override
State<MyIconButton> createState() => _MyIconButtonState();
}
class _MyIconButtonState extends State<MyIconButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _onTapCancel() {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
final scale = 1.0 - (_controller.value * 0.02);
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
final isDark = isDarkMode(context);
final disabledBgColor = isDark
? const Color(0xFF353535)
: const Color(0xFFE0E0E0);
final disabledFgColor = isDark
? const Color(0xFF757575)
: const Color(0xFF9E9E9E);
late final Color bgColor;
late final Color fgColor;
if (widget.variant == MyIconButtonVariant.primary) {
bgColor = primaryColor;
fgColor = Colors.black87;
} else {
bgColor = isDark ? Colors.grey[800]! : Colors.grey[200]!;
fgColor = isDark ? Colors.white : Colors.black87;
}
final childButton = FilledButton(
style: FilledButton.styleFrom(
backgroundColor: bgColor,
foregroundColor: fgColor,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(72, 52),
fixedSize: const Size(72, 52),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
onPressed: isEnabled ? () {} : null,
child: widget.icon,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: isEnabled ? _onTapDown : null,
onTapUp: isEnabled ? _onTapUp : null,
onTapCancel: isEnabled ? _onTapCancel : null,
onTap: widget.onPressed,
onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: AbsorbPointer(
child: childButton,
),
),
);
}
}

View file

@ -17,6 +17,7 @@ class MyInput extends StatefulWidget {
this.autofocus = false,
this.errorText,
this.obscureText = false,
this.dense = false,
super.key,
});
@ -31,6 +32,7 @@ class MyInput extends StatefulWidget {
final bool autofocus;
final String? errorText;
final bool obscureText;
final bool dense;
@override
State<MyInput> createState() => _MyInputState();
@ -165,14 +167,15 @@ class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
isDense: widget.dense,
hintText: widget.hintText,
hintStyle: TextStyle(
color: inputHintColor,
),
filled: true,
fillColor: inputFillColor,
contentPadding: const EdgeInsets.symmetric(
vertical: 18,
contentPadding: EdgeInsets.symmetric(
vertical: widget.dense ? 14 : 18,
horizontal: 24,
),
border: OutlineInputBorder(

View file

@ -95,14 +95,10 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return MediaViewerView(
widget.group,
MediaViewerView.buildPage<void>(
group: widget.group,
initialMessage: widget.message,
);
},
),
).createRoute(context),
);
} else if (widget.mediaService.mediaFile.downloadState ==
DownloadState.pending) {

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
@ -27,7 +28,8 @@ import 'package:twonly/src/services/notifications/background.notifications.dart'
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
@ -41,6 +43,30 @@ class MediaViewerView extends StatefulWidget {
final Message? initialMessage;
static Page<T> buildPage<T>({
required Group group,
LocalKey? key,
Message? initialMessage,
}) {
return CustomTransitionPage<T>(
key: key,
opaque: false,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 250),
reverseTransitionDuration: const Duration(milliseconds: 250),
child: MediaViewerView(
group,
initialMessage: initialMessage,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
}
@override
State<MediaViewerView> createState() => _MediaViewerViewState();
}
@ -81,6 +107,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final HashSet<String> _alreadyOpenedMediaIds = HashSet();
bool _isTransitioning = false;
bool _isZoomed = false;
late PageController _verticalPager;
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
@override
void initState() {
@ -91,6 +120,11 @@ class _MediaViewerViewState extends State<MediaViewerView> {
allMediaFiles = [widget.initialMessage!];
}
_verticalPager = PageController(initialPage: 1);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
});
asyncLoadNextMedia(true);
}
@ -118,9 +152,29 @@ class _MediaViewerViewState extends State<MediaViewerView> {
);
textMessageController.dispose();
_verticalPager
..removeListener(_onVerticalScrollUpdated)
..dispose();
_backdropOpacityNotifier.dispose();
super.dispose();
}
void _onVerticalScrollUpdated() {
if (!_verticalPager.hasClients) return;
final page = _verticalPager.page ?? 1.0;
final linearFraction = min(1, max(0, page)).toDouble();
_backdropOpacityNotifier.value = linearFraction * linearFraction;
}
void _onPageSnapped(int index) {
if (index == 0) {
if (mounted) {
Navigator.pop(context);
}
}
}
void _disposeVideoController() {
final listener = _videoListener;
final controller = videoController;
@ -549,30 +603,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (currentMedia != null &&
!currentMedia!.mediaFile.requiresAuthentication &&
currentMedia!.mediaFile.displayLimitInMilliseconds == null)
OutlinedButton(
style: OutlinedButton.styleFrom(
iconColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
foregroundColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
MyIconButton(
variant: MyIconButtonVariant.secondary,
onPressed: (currentMedia == null) ? null : onPressedSaveToGallery,
child: Row(
children: [
if (imageSaving)
const SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
icon: imageSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
imageSaved
: imageSaved
? const Icon(Icons.check)
: const FaIcon(FontAwesomeIcons.floppyDisk),
],
),
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 20),
),
const SizedBox(width: 10),
IconButton(
@ -614,23 +656,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
),
),
const SizedBox(width: 10),
IconButton.outlined(
icon: const FaIcon(FontAwesomeIcons.message),
MyIconButton(
variant: MyIconButtonVariant.secondary,
onPressed: () async {
displayShortReactions();
setState(() {
showSendTextMessageInput = true;
});
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
),
),
const SizedBox(width: 10),
IconButton.outlined(
icon: const FaIcon(FontAwesomeIcons.camera),
MyIconButton(
onPressed: () async {
nextMediaTimer?.cancel();
progressTimer?.cancel();
@ -651,11 +691,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
await videoController?.play();
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
),
],
);
@ -688,7 +724,28 @@ class _MediaViewerViewState extends State<MediaViewerView> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
backgroundColor: Colors.transparent,
body: ValueListenableBuilder<double>(
valueListenable: _backdropOpacityNotifier,
builder: (context, opacity, child) {
final baseColor = isDarkMode(context) ? Colors.black : Colors.white;
return ColoredBox(
color: baseColor.withValues(alpha: opacity),
child: child,
);
},
child: PageView(
controller: _verticalPager,
scrollDirection: Axis.vertical,
physics: _isZoomed
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
onPageChanged: _onPageSnapped,
children: [
const SizedBox.expand(),
SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
@ -706,24 +763,52 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (videoController != null)
Positioned.fill(
child: PhotoView.customChild(
initialScale: PhotoViewComputedScale.contained,
initialScale:
PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
child: VideoPlayer(
videoController!,
),
),
)
else if (currentMedia != null &&
(currentMedia!.mediaFile.type == MediaType.image ||
currentMedia!.mediaFile.type == MediaType.gif))
(currentMedia!.mediaFile.type ==
MediaType.image ||
currentMedia!.mediaFile.type ==
MediaType.gif))
Positioned.fill(
child: PhotoView(
imageProvider: FileImage(
currentMedia!.tempPath,
),
loadingBuilder: (context, event) => _loader(),
initialScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
initialScale:
PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
@ -752,27 +837,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
),
Container(
padding: const EdgeInsets.only(bottom: 200),
child: Text(context.lang.mediaViewerTwonlyTapToOpen),
child: Text(
context.lang.mediaViewerTwonlyTapToOpen,
),
),
],
),
),
),
Positioned(
left: 10,
top: 10,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close, size: 30),
color: Colors.white,
onPressed: () => Navigator.pop(context),
),
],
),
),
if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready)
currentMedia?.mediaFile.downloadState !=
DownloadState.ready)
Positioned.fill(child: _loader()),
if (canBeSeenUntil != null || progress.value >= 0)
Positioned(
@ -833,37 +908,29 @@ class _MediaViewerViewState extends State<MediaViewerView> {
),
child: Row(
children: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.xmark),
onPressed: () {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
},
),
Expanded(
child: TextField(
child: MyInput(
dense: true,
autofocus: true,
controller: textMessageController,
textCapitalization: TextCapitalization.sentences,
hintText: context.lang.chatListDetailInput,
onChanged: (value) {
setState(() {});
},
onEditingComplete: () {
onSubmitted: (value) {
setState(() {
showSendTextMessageInput = false;
showShortReactions = false;
});
},
decoration: inputTextMessageDeco(
context,
context.lang.chatListDetailInput,
),
),
const SizedBox(width: 10),
MyIconButton(
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 20,
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
if (textMessageController.text.isNotEmpty) {
await insertAndSendTextMessage(
@ -889,7 +956,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
ReactionButtons(
show: showShortReactions,
textInputFocused: showSendTextMessageInput,
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
mediaViewerDistanceFromBottom:
mediaViewerDistanceFromBottom,
groupId: widget.group.groupId,
messageId: currentMessage!.messageId,
emojiKey: emojiKey,
@ -906,6 +974,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
],
),
),
],
),
),
);
}
}

View file

@ -56,17 +56,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
void didUpdateWidget(ReactionButtons oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.show != oldWidget.show) {
if (widget.show) {
_renderAnimations = true;
} else {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted && !widget.show) {
setState(() {
_renderAnimations = false;
});
}
});
}
_renderAnimations = widget.show;
}
}
@ -98,7 +88,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
ignoring: !widget.show,
child: AnimatedOpacity(
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
duration: const Duration(milliseconds: 150),
duration: Duration(milliseconds: widget.show ? 150 : 50),
child: Container(
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 32),