refactor: action buttons and scroll to bottom ui/ux
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
import 'package:socket_io_client/socket_io_client.dart' as IO;
|
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/server_config.dart';
|
import '../models/server_config.dart';
|
||||||
|
|
||||||
class SocketService {
|
class SocketService {
|
||||||
final ServerConfig serverConfig;
|
final ServerConfig serverConfig;
|
||||||
final String? authToken;
|
final String? authToken;
|
||||||
IO.Socket? _socket;
|
io.Socket? _socket;
|
||||||
|
|
||||||
SocketService({required this.serverConfig, required this.authToken});
|
SocketService({required this.serverConfig, required this.authToken});
|
||||||
|
|
||||||
String? get sessionId => _socket?.id;
|
String? get sessionId => _socket?.id;
|
||||||
IO.Socket? get socket => _socket;
|
io.Socket? get socket => _socket;
|
||||||
|
|
||||||
bool get isConnected => _socket?.connected == true;
|
bool get isConnected => _socket?.connected == true;
|
||||||
|
|
||||||
@@ -24,9 +24,9 @@ class SocketService {
|
|||||||
final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), '');
|
final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), '');
|
||||||
final path = '/ws/socket.io';
|
final path = '/ws/socket.io';
|
||||||
|
|
||||||
_socket = IO.io(
|
_socket = io.io(
|
||||||
base,
|
base,
|
||||||
IO.OptionBuilder()
|
io.OptionBuilder()
|
||||||
.setTransports(['websocket'])
|
.setTransports(['websocket'])
|
||||||
.setPath(path)
|
.setPath(path)
|
||||||
.setExtraHeaders(
|
.setExtraHeaders(
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ class VoiceInputService {
|
|||||||
|
|
||||||
// Test if speech recognition is available
|
// Test if speech recognition is available
|
||||||
final supported = await _speech.isSupported();
|
final supported = await _speech.isSupported();
|
||||||
if (!supported)
|
if (!supported) {
|
||||||
return 'Speech recognition service is not available on this device';
|
return 'Speech recognition service is not available on this device';
|
||||||
|
}
|
||||||
|
|
||||||
// Set language if available, then start and stop quickly
|
// Set language if available, then start and stop quickly
|
||||||
if (_selectedLocaleId != null) {
|
if (_selectedLocaleId != null) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import 'chat_page_helpers.dart';
|
|||||||
import '../../../shared/widgets/themed_dialogs.dart';
|
import '../../../shared/widgets/themed_dialogs.dart';
|
||||||
import '../../onboarding/views/onboarding_sheet.dart';
|
import '../../onboarding/views/onboarding_sheet.dart';
|
||||||
import '../../../shared/widgets/sheet_handle.dart';
|
import '../../../shared/widgets/sheet_handle.dart';
|
||||||
|
import '../../../shared/widgets/measure_size.dart';
|
||||||
import '../../../shared/widgets/conduit_components.dart';
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
import '../../../core/services/settings_service.dart';
|
import '../../../core/services/settings_service.dart';
|
||||||
// Removed unused PlatformUtils import
|
// Removed unused PlatformUtils import
|
||||||
@@ -53,6 +54,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
final Set<String> _selectedMessageIds = <String>{};
|
final Set<String> _selectedMessageIds = <String>{};
|
||||||
Timer? _scrollDebounceTimer;
|
Timer? _scrollDebounceTimer;
|
||||||
bool _isDeactivated = false;
|
bool _isDeactivated = false;
|
||||||
|
double _inputHeight = 0; // dynamic input height to position scroll button
|
||||||
|
|
||||||
String _formatModelDisplayName(String name) {
|
String _formatModelDisplayName(String name) {
|
||||||
var display = name.trim();
|
var display = name.trim();
|
||||||
@@ -1028,7 +1030,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
drawerEnableOpenDragGesture: true,
|
drawerEnableOpenDragGesture: true,
|
||||||
drawerDragStartBehavior: DragStartBehavior.down,
|
drawerDragStartBehavior: DragStartBehavior.down,
|
||||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5,
|
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.5,
|
||||||
drawerScrimColor: Colors.black.withOpacity(0.32),
|
drawerScrimColor: Colors.black.withValues(alpha: 0.32),
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
width: (MediaQuery.of(context).size.width * 0.88).clamp(
|
width: (MediaQuery.of(context).size.width * 0.88).clamp(
|
||||||
280.0,
|
280.0,
|
||||||
@@ -1409,6 +1411,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
// Modern Input (root matches input background including safe area)
|
// Modern Input (root matches input background including safe area)
|
||||||
RepaintBoundary(
|
RepaintBoundary(
|
||||||
|
child: MeasureSize(
|
||||||
|
onChange: (size) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_inputHeight = size.height;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
child: ModernChatInput(
|
child: ModernChatInput(
|
||||||
enabled:
|
enabled:
|
||||||
selectedModel != null &&
|
selectedModel != null &&
|
||||||
@@ -1422,13 +1432,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
_handleImageAttachment(fromCamera: true),
|
_handleImageAttachment(fromCamera: true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Floating Scroll to Bottom Button with smooth appear/disappear
|
// Floating Scroll to Bottom Button with smooth appear/disappear
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: Spacing.xxl + Spacing.xxxl,
|
bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm,
|
||||||
right: Spacing.lg,
|
left: 0,
|
||||||
|
right: 0,
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: AnimationDuration.microInteraction,
|
duration: AnimationDuration.microInteraction,
|
||||||
switchInCurve: AnimationCurves.microInteraction,
|
switchInCurve: AnimationCurves.microInteraction,
|
||||||
@@ -1446,12 +1458,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child:
|
child: (_showScrollToBottom &&
|
||||||
(_showScrollToBottom &&
|
|
||||||
!keyboardVisible &&
|
!keyboardVisible &&
|
||||||
ref.watch(chatMessagesProvider).isNotEmpty)
|
ref.watch(chatMessagesProvider).isNotEmpty)
|
||||||
? ClipRRect(
|
? Center(
|
||||||
key: const ValueKey('scroll_to_bottom_visible'),
|
key: const ValueKey('scroll_to_bottom_visible'),
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
AppBorderRadius.floatingButton,
|
AppBorderRadius.floatingButton,
|
||||||
),
|
),
|
||||||
@@ -1488,6 +1500,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(
|
: const SizedBox.shrink(
|
||||||
key: ValueKey('scroll_to_bottom_hidden'),
|
key: ValueKey('scroll_to_bottom_hidden'),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../../core/utils/tool_calls_parser.dart';
|
|||||||
import 'enhanced_image_attachment.dart';
|
import 'enhanced_image_attachment.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'enhanced_attachment.dart';
|
import 'enhanced_attachment.dart';
|
||||||
|
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
||||||
|
|
||||||
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -50,6 +51,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
String _contentSansDetails = '';
|
String _contentSansDetails = '';
|
||||||
bool _allowTypingIndicator = false;
|
bool _allowTypingIndicator = false;
|
||||||
Timer? _typingGateTimer;
|
Timer? _typingGateTimer;
|
||||||
|
// press state handled by shared ChatActionButton
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -911,39 +913,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
required String label,
|
required String label,
|
||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
}) {
|
}) {
|
||||||
return GestureDetector(
|
return ChatActionButton(icon: icon, label: label, onTap: onTap);
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.04),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.08),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: IconSize.sm,
|
|
||||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.8),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: AppTypography.labelMedium,
|
|
||||||
color: context.conduitTheme.textPrimary.withValues(alpha: 0.8),
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
|
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
||||||
|
|
||||||
class UserMessageBubble extends ConsumerStatefulWidget {
|
class UserMessageBubble extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -42,6 +43,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
bool _showActions = false;
|
bool _showActions = false;
|
||||||
late AnimationController _fadeController;
|
late AnimationController _fadeController;
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
|
// press state handled by shared ChatActionButton
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -532,47 +534,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
required String label,
|
required String label,
|
||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
}) {
|
}) {
|
||||||
return GestureDetector(
|
return ChatActionButton(icon: icon, label: label, onTap: onTap);
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.actionButtonPadding,
|
|
||||||
vertical: Spacing.xs,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceBackground.withValues(
|
|
||||||
alpha: Alpha.buttonHover,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.actionButton),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.textPrimary.withValues(
|
|
||||||
alpha: Alpha.subtle,
|
|
||||||
),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: IconSize.small,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: AppTypography.labelStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().scale(
|
|
||||||
duration: AnimationDuration.buttonPress,
|
|
||||||
curve: AnimationCurves.buttonPress,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUserActionButtons() {
|
Widget _buildUserActionButtons() {
|
||||||
|
|||||||
@@ -115,7 +115,19 @@ abstract class OutboundTask with _$OutboundTask {
|
|||||||
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
||||||
_$OutboundTaskFromJson(json);
|
_$OutboundTaskFromJson(json);
|
||||||
|
|
||||||
String get threadKey => (conversationId == null || conversationId!.isEmpty)
|
// Provide a unified nullable conversationId across variants
|
||||||
|
String? get maybeConversationId => map(
|
||||||
|
sendTextMessage: (t) => t.conversationId,
|
||||||
|
uploadMedia: (t) => t.conversationId,
|
||||||
|
executeToolCall: (t) => t.conversationId,
|
||||||
|
generateImage: (t) => t.conversationId,
|
||||||
|
saveConversation: (t) => t.conversationId,
|
||||||
|
generateTitle: (t) => t.conversationId,
|
||||||
|
imageToDataUrl: (t) => t.conversationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
String get threadKey =>
|
||||||
|
(maybeConversationId == null || maybeConversationId!.isEmpty)
|
||||||
? 'new'
|
? 'new'
|
||||||
: conversationId!;
|
: maybeConversationId!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
|
|||||||
Future<void> cancelByConversation(String conversationId) async {
|
Future<void> cancelByConversation(String conversationId) async {
|
||||||
state = [
|
state = [
|
||||||
for (final t in state)
|
for (final t in state)
|
||||||
if ((t.conversationId ?? '') == conversationId &&
|
if ((t.maybeConversationId ?? '') == conversationId &&
|
||||||
(t.status == TaskStatus.queued || t.status == TaskStatus.running))
|
(t.status == TaskStatus.queued || t.status == TaskStatus.running))
|
||||||
t.copyWith(
|
t.copyWith(
|
||||||
status: TaskStatus.cancelled,
|
status: TaskStatus.cancelled,
|
||||||
|
|||||||
@@ -343,9 +343,13 @@ class TaskWorker {
|
|||||||
final b64 = base64Encode(bytes);
|
final b64 = base64Encode(bytes);
|
||||||
final ext = path.extension(task.fileName).toLowerCase();
|
final ext = path.extension(task.fileName).toLowerCase();
|
||||||
String mime = 'image/png';
|
String mime = 'image/png';
|
||||||
if (ext == '.jpg' || ext == '.jpeg') mime = 'image/jpeg';
|
if (ext == '.jpg' || ext == '.jpeg') {
|
||||||
else if (ext == '.gif') mime = 'image/gif';
|
mime = 'image/jpeg';
|
||||||
else if (ext == '.webp') mime = 'image/webp';
|
} else if (ext == '.gif') {
|
||||||
|
mime = 'image/gif';
|
||||||
|
} else if (ext == '.webp') {
|
||||||
|
mime = 'image/webp';
|
||||||
|
}
|
||||||
final dataUrl = 'data:$mime;base64,$b64';
|
final dataUrl = 'data:$mime;base64,$b64';
|
||||||
|
|
||||||
// Mark as completed with data URL as fileId
|
// Mark as completed with data URL as fileId
|
||||||
|
|||||||
103
lib/shared/widgets/chat_action_button.dart
Normal file
103
lib/shared/widgets/chat_action_button.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:conduit/shared/theme/theme_extensions.dart';
|
||||||
|
import 'package:conduit/core/services/platform_service.dart';
|
||||||
|
import 'package:conduit/core/services/settings_service.dart';
|
||||||
|
|
||||||
|
class ChatActionButton extends ConsumerStatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
const ChatActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.onTap,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
this.borderRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChatActionButton> createState() => _ChatActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatActionButtonState extends ConsumerState<ChatActionButton> {
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final hapticEnabled = ref.read(hapticEnabledProvider);
|
||||||
|
final radius = widget.borderRadius ?? BorderRadius.circular(AppBorderRadius.lg);
|
||||||
|
final overlay = theme.buttonPrimary.withValues(alpha: 0.08);
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: widget.label,
|
||||||
|
waitDuration: const Duration(milliseconds: 600),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: widget.label,
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: _pressed ? 0.98 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: radius,
|
||||||
|
splashColor: overlay,
|
||||||
|
highlightColor: theme.textPrimary.withValues(alpha: 0.06),
|
||||||
|
onHighlightChanged: (v) => setState(() => _pressed = v),
|
||||||
|
onTap: widget.onTap == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
PlatformService.hapticFeedbackWithSettings(
|
||||||
|
type: HapticType.selection,
|
||||||
|
hapticEnabled: hapticEnabled,
|
||||||
|
);
|
||||||
|
widget.onTap!();
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.textPrimary.withValues(alpha: 0.04),
|
||||||
|
borderRadius: radius,
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.textPrimary.withValues(alpha: 0.08),
|
||||||
|
width: BorderWidth.regular,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: widget.padding,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.icon,
|
||||||
|
size: IconSize.sm,
|
||||||
|
color: theme.textPrimary.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppTypography.labelMedium,
|
||||||
|
color: theme.textPrimary.withValues(alpha: 0.8),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class ConduitMarkdownConfig {
|
|||||||
LinkConfig(
|
LinkConfig(
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: theme.buttonPrimary,
|
color: theme.buttonPrimary,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.none,
|
||||||
),
|
),
|
||||||
onTap: (url) async {
|
onTap: (url) async {
|
||||||
if (await canLaunchUrlString(url)) {
|
if (await canLaunchUrlString(url)) {
|
||||||
|
|||||||
40
lib/shared/widgets/measure_size.dart
Normal file
40
lib/shared/widgets/measure_size.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
typedef OnWidgetSizeChange = void Function(Size size);
|
||||||
|
|
||||||
|
class MeasureSize extends SingleChildRenderObjectWidget {
|
||||||
|
final OnWidgetSizeChange onChange;
|
||||||
|
|
||||||
|
const MeasureSize({super.key, required this.onChange, required Widget child})
|
||||||
|
: super(child: child);
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderObject createRenderObject(BuildContext context) {
|
||||||
|
return _MeasureSizeRenderObject(onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(
|
||||||
|
BuildContext context, covariant _MeasureSizeRenderObject renderObject) {
|
||||||
|
renderObject.onChange = onChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MeasureSizeRenderObject extends RenderProxyBox {
|
||||||
|
_MeasureSizeRenderObject(this.onChange);
|
||||||
|
|
||||||
|
OnWidgetSizeChange onChange;
|
||||||
|
Size? _oldSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout() {
|
||||||
|
super.performLayout();
|
||||||
|
Size? newSize = child?.size;
|
||||||
|
if (_oldSize == newSize || newSize == null) return;
|
||||||
|
_oldSize = newSize;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
onChange(newSize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user