💄 Optimize task overlay styles

This commit is contained in:
2026-01-11 02:48:44 +08:00
parent c93b543da9
commit eec181da55
2 changed files with 374 additions and 238 deletions

View File

@@ -17,7 +17,7 @@ import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/cmp/pattle.dart'; import 'package:island/widgets/cmp/pattle.dart';
import 'package:island/widgets/upload_overlay.dart'; import 'package:island/widgets/task_overlay.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shake/shake.dart'; import 'package:shake/shake.dart';
@@ -258,7 +258,7 @@ class WindowScaffold extends HookConsumerWidget {
], ],
), ),
_WebSocketIndicator(), _WebSocketIndicator(),
const UploadOverlay(), const TaskOverlay(),
if (showPalette.value) if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false), CommandPattleWidget(onDismiss: () => showPalette.value = false),
], ],
@@ -271,7 +271,7 @@ class WindowScaffold extends HookConsumerWidget {
children: [ children: [
Positioned.fill(child: child), Positioned.fill(child: child),
_WebSocketIndicator(), _WebSocketIndicator(),
const UploadOverlay(), const TaskOverlay(),
if (showPalette.value) if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false), CommandPattleWidget(onDismiss: () => showPalette.value = false),
], ],

View File

@@ -11,8 +11,8 @@ import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
class UploadOverlay extends HookConsumerWidget { class TaskOverlay extends HookConsumerWidget {
const UploadOverlay({super.key}); const TaskOverlay({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -32,7 +32,9 @@ class UploadOverlay extends HookConsumerWidget {
final isVisibleOverride = useState<bool?>(null); final isVisibleOverride = useState<bool?>(null);
final pendingHide = useState(false); final pendingHide = useState(false);
final isExpandedLocal = useState(false); final isExpandedLocal = useState(false);
final isCompactLocal = useState(true); // Start compact
final autoHideTimer = useState<Timer?>(null); final autoHideTimer = useState<Timer?>(null);
final autoCompactTimer = useState<Timer?>(null);
final allFinished = activeTasks.every( final allFinished = activeTasks.every(
(task) => (task) =>
@@ -69,14 +71,35 @@ class UploadOverlay extends HookConsumerWidget {
} }
return null; return null;
}, [allFinished, activeTasks, isExpandedLocal.value, pendingHide.value]); }, [allFinished, activeTasks, isExpandedLocal.value, pendingHide.value]);
final isDesktop = isWideScreen(context);
// Auto-compact timer for mobile when expanded
useEffect(() {
if (!isDesktop && !isCompactLocal.value) {
// Start timer to auto-compact after 5 seconds
autoCompactTimer.value?.cancel();
autoCompactTimer.value = Timer(const Duration(seconds: 5), () {
isCompactLocal.value = true;
});
} else {
autoCompactTimer.value?.cancel();
autoCompactTimer.value = null;
}
return null;
}, [isCompactLocal.value, isDesktop]);
final isVisible = final isVisible =
(isVisibleOverride.value ?? activeTasks.isNotEmpty) && (isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
!pendingHide.value; !pendingHide.value;
final slideController = useAnimationController( final slideController = useAnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
); );
final isTopPositioned = !isDesktop; // Mobile: top, Desktop: bottom
final slideAnimation = Tween<Offset>( final slideAnimation = Tween<Offset>(
begin: const Offset(0, 1), // Start from below the screen begin: isTopPositioned
? const Offset(0, -1)
: const Offset(0, 1), // Start from above/below the screen
end: Offset.zero, // End at normal position end: Offset.zero, // End at normal position
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut)); ).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
@@ -95,33 +118,47 @@ class UploadOverlay extends HookConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isDesktop = isWideScreen(context);
return Positioned( return Positioned(
bottom: 0, top: isTopPositioned ? 0 : null,
bottom: !isTopPositioned ? 0 : null,
left: isDesktop ? null : 0, left: isDesktop ? null : 0,
right: isDesktop ? 24 : 0, right: isDesktop ? 24 : 0,
child: SlideTransition( child: SlideTransition(
position: slideAnimation, position: slideAnimation,
child: _UploadOverlayContent( child:
_TaskOverlayContent(
activeTasks: activeTasks, activeTasks: activeTasks,
isExpanded: isExpandedLocal.value, isExpanded: isExpandedLocal.value,
onExpansionChanged: (expanded) => isExpandedLocal.value = expanded, isCompact: isCompactLocal.value,
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom), onExpansionChanged: (expanded) =>
isExpandedLocal.value = expanded,
onCompactChanged: (compact) => isCompactLocal.value = compact,
).padding(
top: isTopPositioned
? MediaQuery.of(context).padding.top + 16
: 0,
bottom: !isTopPositioned
? 16 + MediaQuery.of(context).padding.bottom
: 0,
),
), ),
); );
} }
} }
class _UploadOverlayContent extends HookConsumerWidget { class _TaskOverlayContent extends HookConsumerWidget {
final List<DriveTask> activeTasks; final List<DriveTask> activeTasks;
final bool isExpanded; final bool isExpanded;
final bool isCompact;
final Function(bool)? onExpansionChanged; final Function(bool)? onExpansionChanged;
final Function(bool)? onCompactChanged;
const _UploadOverlayContent({ const _TaskOverlayContent({
required this.activeTasks, required this.activeTasks,
required this.isExpanded, required this.isExpanded,
required this.isCompact,
this.onExpansionChanged, this.onExpansionChanged,
this.onCompactChanged,
}); });
@override @override
@@ -130,11 +167,16 @@ class _UploadOverlayContent extends HookConsumerWidget {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
initialValue: 0.0, initialValue: 0.0,
); );
final heightAnimation = useAnimation( final compactHeight = 32.0;
Tween<double>(begin: 60, end: 400).animate( final collapsedHeight = 60.0;
CurvedAnimation(parent: animationController, curve: Curves.easeInOut), final expandedHeight = 400.0;
),
); final currentHeight = isCompact
? compactHeight
: isExpanded
? expandedHeight
: collapsedHeight;
final opacityAnimation = useAnimation( final opacityAnimation = useAnimation(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut), CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
); );
@@ -152,28 +194,86 @@ class _UploadOverlayContent extends HookConsumerWidget {
final taskNotifier = ref.read(uploadTasksProvider.notifier); final taskNotifier = ref.read(uploadTasksProvider.notifier);
return Padding( void handleInteraction() {
padding: EdgeInsets.only( if (isCompact) {
bottom: isMobile ? 16 : 24, onCompactChanged?.call(false);
left: isMobile ? 16 : 0, } else if (!isExpanded) {
right: isMobile ? 16 : 24, onExpansionChanged?.call(true);
), } else {
child: GestureDetector( onExpansionChanged?.call(false);
onTap: () => onExpansionChanged?.call(!isExpanded), }
child: AnimatedBuilder( }
animation: animationController,
builder: (context, child) { Widget content = AnimatedContainer(
return Material( duration: const Duration(milliseconds: 300),
elevation: 8 + (opacityAnimation * 4),
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut, curve: Curves.easeInOut,
width: isMobile ? MediaQuery.of(context).size.width - 32 : 320, decoration: BoxDecoration(
height: heightAnimation, color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
width: isCompact
? _getCompactWidth(activeTasks)
: (isMobile ? MediaQuery.of(context).size.width - 32 : 320),
height: currentHeight,
child: GestureDetector(
onTap: isMobile ? handleInteraction : null,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
child: isCompact
? // Compact view with progress bar background and text
Container(
key: const ValueKey('compact'),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Icon(
_getOverallStatusIcon(activeTasks),
size: 16,
color: Theme.of(context).colorScheme.primary,
),
Expanded(
child: Text(
activeTasks.isEmpty
? '0 tasks'
: _getOverallStatusText(activeTasks),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
value: _getOverallProgress(activeTasks),
strokeWidth: 3,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
padding: EdgeInsets.zero,
),
),
],
).padding(horizontal: 12),
)
: Container(
key: const ValueKey('expanded'),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -183,7 +283,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
// Upload icon with animation // Task icon with animation
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) { transitionBuilder: (child, animation) {
@@ -211,7 +311,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
children: [ children: [
Text( Text(
isExpanded isExpanded
? 'uploadTasks'.tr() ? 'tasks'.tr()
: _getOverallStatusText(activeTasks), : _getOverallStatusText(activeTasks),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
@@ -361,25 +461,46 @@ class _UploadOverlayContent extends HookConsumerWidget {
), ),
), ),
), ),
),
),
); );
},
), // Add MouseRegion for desktop hover
if (!isMobile) {
content = MouseRegion(
onEnter: (_) => onCompactChanged?.call(false),
onExit: (_) => onCompactChanged?.call(true),
child: content,
);
}
if (isCompact) {
content = Center(child: content);
}
return Padding(
padding: EdgeInsets.only(
bottom: isMobile ? 16 : 24,
left: isMobile ? 16 : 0,
right: isMobile ? 16 : 24,
), ),
child: content,
); );
} }
double _getOverallProgress(List<DriveTask> tasks) { double _getOverallProgress(List<DriveTask> tasks) {
if (tasks.isEmpty) return 0.0; if (tasks.isEmpty) return 0.0;
final totalProgress = tasks.fold<double>( final totalProgress = tasks.fold<double>(0.0, (sum, task) {
0.0, final byteProgress = task.fileSize > 0
(sum, task) => ? task.uploadedBytes / task.fileSize
sum + : 0.0;
return sum +
(task.status == DriveTaskStatus.inProgress (task.status == DriveTaskStatus.inProgress
? task.progress ? byteProgress
: task.status == DriveTaskStatus.completed : task.status == DriveTaskStatus.completed
? 1 ? 1
: 0), : 0);
); });
return totalProgress / tasks.length; return totalProgress / tasks.length;
} }
@@ -482,6 +603,19 @@ class _UploadOverlayContent extends HookConsumerWidget {
task.status == DriveTaskStatus.expired, task.status == DriveTaskStatus.expired,
); );
} }
double _getCompactWidth(List<DriveTask> tasks) {
// Base width for icon and padding
double width = 16 + 12 + 12; // icon size + padding + spacing
// Add text width estimation
final text = activeTasks.isEmpty ? '0 tasks' : _getOverallStatusText(tasks);
// Rough estimation: 8px per character
width += text.length * 8.0;
// Cap at reasonable maximum
return width.clamp(200, 280);
}
} }
class UploadTaskTile extends StatefulWidget { class UploadTaskTile extends StatefulWidget {
@@ -551,7 +685,9 @@ class _UploadTaskTileState extends State<UploadTaskTile>
child: Padding( child: Padding(
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: widget.task.progress, value: widget.task.fileSize > 0
? widget.task.uploadedBytes / widget.task.fileSize
: 0.0,
strokeWidth: 2.5, strokeWidth: 2.5,
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,