- Implemented new navigation system with NavigationProvider and route management - Added adaptive theme system with ThemeProvider for better theme handling - Introduced glassmorphism design elements (app bars, scaffolds, cards) - Added advanced animations (spring animations, page transitions, staggered lists) - Implemented performance optimizations (memory manager, lazy loading) - Refactored Analysis screen into modular components - Added floating navigation bar with haptic feedback - Improved subscription cards with swipe actions - Enhanced skeleton loading with better animations - Added cached network image support - Improved overall app architecture and code organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
268 lines
7.5 KiB
Dart
268 lines
7.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'dart:math' as math;
|
|
import '../theme/app_colors.dart';
|
|
import '../utils/haptic_feedback_helper.dart';
|
|
import 'glassmorphism_card.dart';
|
|
|
|
class ExpandableFab extends StatefulWidget {
|
|
final List<FabAction> actions;
|
|
final double distance;
|
|
|
|
const ExpandableFab({
|
|
super.key,
|
|
required this.actions,
|
|
this.distance = 100.0,
|
|
});
|
|
|
|
@override
|
|
State<ExpandableFab> createState() => _ExpandableFabState();
|
|
}
|
|
|
|
class _ExpandableFabState extends State<ExpandableFab>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _expandAnimation;
|
|
late Animation<double> _rotateAnimation;
|
|
bool _isExpanded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
|
|
_expandAnimation = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeOutBack,
|
|
reverseCurve: Curves.easeInBack,
|
|
);
|
|
|
|
_rotateAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: math.pi / 4,
|
|
).animate(CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggle() {
|
|
setState(() {
|
|
_isExpanded = !_isExpanded;
|
|
});
|
|
|
|
if (_isExpanded) {
|
|
HapticFeedbackHelper.mediumImpact();
|
|
_controller.forward();
|
|
} else {
|
|
HapticFeedbackHelper.lightImpact();
|
|
_controller.reverse();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
alignment: Alignment.bottomRight,
|
|
children: [
|
|
// 배경 오버레이 (확장 시)
|
|
if (_isExpanded)
|
|
GestureDetector(
|
|
onTap: _toggle,
|
|
child: AnimatedBuilder(
|
|
animation: _expandAnimation,
|
|
builder: (context, child) {
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.3 * _expandAnimation.value),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// 액션 버튼들
|
|
...widget.actions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final action = entry.value;
|
|
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
|
|
|
return AnimatedBuilder(
|
|
animation: _expandAnimation,
|
|
builder: (context, child) {
|
|
final distance = widget.distance * _expandAnimation.value;
|
|
final x = distance * math.cos(angle);
|
|
final y = distance * math.sin(angle);
|
|
|
|
return Transform.translate(
|
|
offset: Offset(-x, -y),
|
|
child: ScaleTransition(
|
|
scale: _expandAnimation,
|
|
child: FloatingActionButton.small(
|
|
heroTag: 'fab_action_$index',
|
|
onPressed: _isExpanded
|
|
? () {
|
|
HapticFeedbackHelper.lightImpact();
|
|
_toggle();
|
|
action.onPressed();
|
|
}
|
|
: null,
|
|
backgroundColor: action.color ?? AppColors.primaryColor,
|
|
child: Icon(
|
|
action.icon,
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
|
|
// 메인 FAB
|
|
AnimatedBuilder(
|
|
animation: _rotateAnimation,
|
|
builder: (context, child) {
|
|
return Transform.rotate(
|
|
angle: _rotateAnimation.value,
|
|
child: FloatingActionButton(
|
|
onPressed: _toggle,
|
|
backgroundColor: AppColors.primaryColor,
|
|
child: Icon(
|
|
_isExpanded ? Icons.close : Icons.add,
|
|
size: 28,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// 라벨 표시
|
|
if (_isExpanded)
|
|
...widget.actions.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final action = entry.value;
|
|
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
|
|
|
return AnimatedBuilder(
|
|
animation: _expandAnimation,
|
|
builder: (context, child) {
|
|
final distance = widget.distance * _expandAnimation.value;
|
|
final x = distance * math.cos(angle);
|
|
final y = distance * math.sin(angle);
|
|
|
|
return Transform.translate(
|
|
offset: Offset(-x - 80, -y),
|
|
child: FadeTransition(
|
|
opacity: _expandAnimation,
|
|
child: GlassmorphismCard(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
borderRadius: 8,
|
|
blur: 10,
|
|
child: Text(
|
|
action.label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class FabAction {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
final Color? color;
|
|
|
|
const FabAction({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
this.color,
|
|
});
|
|
}
|
|
|
|
// 드래그 가능한 FAB
|
|
class DraggableFab extends StatefulWidget {
|
|
final Widget child;
|
|
final EdgeInsets? padding;
|
|
|
|
const DraggableFab({
|
|
super.key,
|
|
required this.child,
|
|
this.padding,
|
|
});
|
|
|
|
@override
|
|
State<DraggableFab> createState() => _DraggableFabState();
|
|
}
|
|
|
|
class _DraggableFabState extends State<DraggableFab> {
|
|
Offset _position = const Offset(20, 20);
|
|
bool _isDragging = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenSize = MediaQuery.of(context).size;
|
|
final padding = widget.padding ?? const EdgeInsets.all(20);
|
|
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
right: _position.dx,
|
|
bottom: _position.dy,
|
|
child: GestureDetector(
|
|
onPanStart: (_) {
|
|
setState(() => _isDragging = true);
|
|
HapticFeedbackHelper.lightImpact();
|
|
},
|
|
onPanUpdate: (details) {
|
|
setState(() {
|
|
_position = Offset(
|
|
(_position.dx - details.delta.dx).clamp(
|
|
padding.right,
|
|
screenSize.width - 100 - padding.left,
|
|
),
|
|
(_position.dy - details.delta.dy).clamp(
|
|
padding.bottom,
|
|
screenSize.height - 200 - padding.top,
|
|
),
|
|
);
|
|
});
|
|
},
|
|
onPanEnd: (_) {
|
|
setState(() => _isDragging = false);
|
|
HapticFeedbackHelper.lightImpact();
|
|
},
|
|
child: AnimatedScale(
|
|
duration: const Duration(milliseconds: 150),
|
|
scale: _isDragging ? 0.9 : 1.0,
|
|
child: widget.child,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |