Major UI/UX and architecture improvements
- 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>
This commit is contained in:
83
lib/widgets/analysis/analysis_badge.dart
Normal file
83
lib/widgets/analysis/analysis_badge.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
|
||||
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||
class AnalysisBadge extends StatelessWidget {
|
||||
final double size;
|
||||
final Color borderColor;
|
||||
final SubscriptionModel subscription;
|
||||
|
||||
const AnalysisBadge({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.borderColor,
|
||||
required this.subscription,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: PieChart.defaultDuration,
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
subscription.serviceName.length > 5
|
||||
? '${subscription.serviceName.substring(0, 5)}...'
|
||||
: subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.formatAmount(
|
||||
subscription.monthlyCost,
|
||||
subscription.currency,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final amountText = snapshot.data!;
|
||||
// 금액이 너무 길면 축약
|
||||
final displayText = amountText.length > 8
|
||||
? amountText.replaceAll('원', '').trim()
|
||||
: amountText;
|
||||
return Text(
|
||||
displayText,
|
||||
style: const TextStyle(
|
||||
fontSize: 7,
|
||||
color: Colors.black54,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/widgets/analysis/analysis_screen_spacer.dart
Normal file
19
lib/widgets/analysis/analysis_screen_spacer.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 분석 화면에서 사용하는 간격 위젯
|
||||
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
|
||||
class AnalysisScreenSpacer extends StatelessWidget {
|
||||
final double height;
|
||||
|
||||
const AnalysisScreenSpacer({
|
||||
super.key,
|
||||
this.height = 24,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(height: height),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
lib/widgets/analysis/event_analysis_card.dart
Normal file
272
lib/widgets/analysis/event_analysis_card.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/subscription_provider.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
|
||||
/// 이벤트 할인 현황을 보여주는 카드 위젯
|
||||
class EventAnalysisCard extends StatelessWidget {
|
||||
final AnimationController animationController;
|
||||
|
||||
const EventAnalysisCard({
|
||||
super.key,
|
||||
required this.animationController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Consumer<SubscriptionProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: provider.activeEventSubscriptions.isNotEmpty
|
||||
? FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||
)),
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
borderRadius: 16,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '이벤트 할인 현황',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFFF6B6B),
|
||||
Color(0xFFFE7E7E),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const FaIcon(
|
||||
FontAwesomeIcons.fire,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${provider.activeEventSubscriptions.length}개 진행중',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||
const Color(0xFFFF8787).withValues(alpha: 0.1),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.savings,
|
||||
color: Color(0xFFFF6B6B),
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ThemedText(
|
||||
'월간 절약 금액',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmount(
|
||||
provider.calculateTotalSavings(),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const ThemedText(
|
||||
'진행중인 이벤트',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...provider.activeEventSubscriptions.map((sub) {
|
||||
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
|
||||
final discountRate =
|
||||
((savings / sub.originalPrice) * 100).round();
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText(
|
||||
sub.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatAmount(
|
||||
sub.originalPrice,
|
||||
sub.currency),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ThemedText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration
|
||||
.lineThrough,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatAmount(
|
||||
sub.eventPrice ?? sub.originalPrice,
|
||||
sub.currency),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ThemedText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
color:
|
||||
Color(0xFF10B981),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B)
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'$discountRate% 할인',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
214
lib/widgets/analysis/monthly_expense_chart_card.dart
Normal file
214
lib/widgets/analysis/monthly_expense_chart_card.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../services/currency_util.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
|
||||
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> monthlyData;
|
||||
final AnimationController animationController;
|
||||
|
||||
const MonthlyExpenseChartCard({
|
||||
super.key,
|
||||
required this.monthlyData,
|
||||
required this.animationController,
|
||||
});
|
||||
|
||||
// 월간 지출 차트 데이터
|
||||
List<BarChartGroupData> _getMonthlyBarGroups() {
|
||||
final List<BarChartGroupData> barGroups = [];
|
||||
final calculatedMax = monthlyData.fold<double>(
|
||||
0, (max, data) => math.max(max, data['totalExpense'] as double));
|
||||
final maxAmount = calculatedMax > 0 ? calculatedMax : 100000.0; // 기본값 10만원
|
||||
|
||||
for (int i = 0; i < monthlyData.length; i++) {
|
||||
final data = monthlyData[i];
|
||||
barGroups.add(
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: data['totalExpense'],
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFF3B82F6).withValues(alpha: 0.7),
|
||||
const Color(0xFF60A5FA),
|
||||
],
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
),
|
||||
width: 18,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
backDrawRodData: BackgroundBarChartRodData(
|
||||
show: true,
|
||||
toY: maxAmount + (maxAmount * 0.1),
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return barGroups;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
||||
),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
||||
)),
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
borderRadius: 16,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '월별 지출 현황',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText.subtitle(
|
||||
text: '최근 6개월간 추이',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 바 차트
|
||||
AspectRatio(
|
||||
aspectRatio: 1.6,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: math.max(
|
||||
monthlyData.fold<double>(
|
||||
0,
|
||||
(max, data) => math.max(
|
||||
max, data['totalExpense'] as double)) *
|
||||
1.2,
|
||||
100000.0 // 최소값 10만원
|
||||
),
|
||||
barGroups: _getMonthlyBarGroups(),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: math.max(
|
||||
monthlyData.fold<double>(
|
||||
0,
|
||||
(max, data) => math.max(max,
|
||||
data['totalExpense'] as double)) /
|
||||
4,
|
||||
25000.0 // 최소 간격 2.5만원
|
||||
),
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ThemedText.caption(
|
||||
text: monthlyData[value.toInt()]
|
||||
['monthName'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barTouchData: BarTouchData(
|
||||
enabled: true,
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
tooltipBgColor: Colors.blueGrey.shade800,
|
||||
tooltipRoundedRadius: 8,
|
||||
getTooltipItem:
|
||||
(group, groupIndex, rod, rodIndex) {
|
||||
return BarTooltipItem(
|
||||
'${monthlyData[group.x]['monthName']}\n',
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: CurrencyUtil.formatTotalAmount(
|
||||
monthlyData[group.x]['totalExpense']
|
||||
as double),
|
||||
style: const TextStyle(
|
||||
color: Colors.yellow,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: ThemedText.caption(
|
||||
text: '월 구독 지출',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
294
lib/widgets/analysis/subscription_pie_chart_card.dart
Normal file
294
lib/widgets/analysis/subscription_pie_chart_card.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import 'analysis_badge.dart';
|
||||
|
||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||
class SubscriptionPieChartCard extends StatelessWidget {
|
||||
final List<SubscriptionModel> subscriptions;
|
||||
final AnimationController animationController;
|
||||
final int touchedIndex;
|
||||
final Function(int) onPieTouch;
|
||||
|
||||
const SubscriptionPieChartCard({
|
||||
super.key,
|
||||
required this.subscriptions,
|
||||
required this.animationController,
|
||||
required this.touchedIndex,
|
||||
required this.onPieTouch,
|
||||
});
|
||||
|
||||
// 파이 차트 섹션 데이터
|
||||
List<PieChartSectionData> _getPieSections() {
|
||||
if (subscriptions.isEmpty) return [];
|
||||
|
||||
final colors = [
|
||||
const Color(0xFF3B82F6),
|
||||
const Color(0xFF10B981),
|
||||
const Color(0xFFF59E0B),
|
||||
const Color(0xFFEF4444),
|
||||
const Color(0xFF8B5CF6),
|
||||
const Color(0xFF0EA5E9),
|
||||
const Color(0xFFEC4899),
|
||||
];
|
||||
|
||||
// 개별 구독의 비율 계산을 위한 값들
|
||||
List<double> sectionValues = [];
|
||||
|
||||
// 각 구독의 원화 환산 금액 또는 원화 금액을 계산
|
||||
for (var subscription in subscriptions) {
|
||||
double value = subscription.monthlyCost;
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD의 경우 마지막으로 조회된 환율로 대략적인 계산
|
||||
// (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용)
|
||||
const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴)
|
||||
value = value * rate;
|
||||
}
|
||||
sectionValues.add(value);
|
||||
}
|
||||
|
||||
// 총합 계산
|
||||
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
|
||||
|
||||
// 섹션 데이터 생성
|
||||
return List.generate(subscriptions.length, (i) {
|
||||
final subscription = subscriptions[i];
|
||||
final percentage = (sectionValues[i] / sectionsTotal) * 100;
|
||||
final index = i % colors.length;
|
||||
final isTouched = touchedIndex == i;
|
||||
final fontSize = isTouched ? 16.0 : 12.0;
|
||||
final radius = isTouched ? 105.0 : 100.0;
|
||||
|
||||
return PieChartSectionData(
|
||||
value: sectionValues[i],
|
||||
title: '${percentage.toStringAsFixed(1)}%',
|
||||
titleStyle: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||
],
|
||||
),
|
||||
color: colors[index],
|
||||
radius: radius,
|
||||
titlePositionPercentageOffset: 0.6,
|
||||
badgeWidget: isTouched
|
||||
? AnalysisBadge(
|
||||
size: 40,
|
||||
borderColor: colors[index],
|
||||
subscription: subscription,
|
||||
)
|
||||
: null,
|
||||
badgePositionPercentageOffset: .98,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||
),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||
)),
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
borderRadius: 16,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '구독 서비스 비율',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfo(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText.subtitle(
|
||||
text: '월 지출 기준',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: subscriptions.isEmpty
|
||||
? const SizedBox(
|
||||
height: 250,
|
||||
child: Center(
|
||||
child: ThemedText(
|
||||
'구독중인 서비스가 없습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: 250,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 60,
|
||||
sections: _getPieSections(),
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback: (FlTouchEvent event,
|
||||
pieTouchResponse) {
|
||||
if (!event
|
||||
.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
pieTouchResponse
|
||||
.touchedSection ==
|
||||
null) {
|
||||
onPieTouch(-1);
|
||||
return;
|
||||
}
|
||||
onPieTouch(pieTouchResponse
|
||||
.touchedSection!
|
||||
.touchedSectionIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 서비스 목록
|
||||
Column(
|
||||
children: subscriptions.isEmpty
|
||||
? []
|
||||
: List.generate(
|
||||
subscriptions.length,
|
||||
(index) {
|
||||
final subscription =
|
||||
subscriptions[index];
|
||||
final color = [
|
||||
const Color(0xFF3B82F6),
|
||||
const Color(0xFF10B981),
|
||||
const Color(0xFFF59E0B),
|
||||
const Color(0xFFEF4444),
|
||||
const Color(0xFF8B5CF6),
|
||||
const Color(0xFF0EA5E9),
|
||||
const Color(0xFFEC4899),
|
||||
][index % 7];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ThemedText(
|
||||
subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatSubscriptionAmount(
|
||||
subscription),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ThemedText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
228
lib/widgets/analysis/total_expense_summary_card.dart
Normal file
228
lib/widgets/analysis/total_expense_summary_card.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../../utils/haptic_feedback_helper.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
|
||||
/// 총 지출 요약을 보여주는 카드 위젯
|
||||
class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
final List<SubscriptionModel> subscriptions;
|
||||
final double totalExpense;
|
||||
final AnimationController animationController;
|
||||
|
||||
const TotalExpenseSummaryCard({
|
||||
super.key,
|
||||
required this.subscriptions,
|
||||
required this.totalExpense,
|
||||
required this.animationController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||
),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||
)),
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
borderRadius: 16,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: '총 지출 요약',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.content_copy),
|
||||
iconSize: 20,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: totalExpenseText));
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('총 지출액이 복사되었습니다: $totalExpenseText'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ThemedText.subtitle(
|
||||
text: '월 단위 총액',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '총 지출',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmount(totalExpense),
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.listCheck,
|
||||
size: 16,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '총 서비스',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
'${subscriptions.length}개',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.chartLine,
|
||||
size: 16,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: '평균 요금',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmount(
|
||||
subscriptions.isEmpty
|
||||
? 0
|
||||
: totalExpense / subscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
311
lib/widgets/animated_page_transitions.dart
Normal file
311
lib/widgets/animated_page_transitions.dart
Normal file
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 슬라이드 + 페이드 전환
|
||||
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final AxisDirection direction;
|
||||
|
||||
SlidePageRoute({
|
||||
required this.page,
|
||||
this.direction = AxisDirection.right,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
Offset begin;
|
||||
switch (direction) {
|
||||
case AxisDirection.right:
|
||||
begin = const Offset(-1.0, 0.0);
|
||||
break;
|
||||
case AxisDirection.left:
|
||||
begin = const Offset(1.0, 0.0);
|
||||
break;
|
||||
case AxisDirection.up:
|
||||
begin = const Offset(0.0, 1.0);
|
||||
break;
|
||||
case AxisDirection.down:
|
||||
begin = const Offset(0.0, -1.0);
|
||||
break;
|
||||
}
|
||||
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
var offsetAnimation = animation.drive(tween);
|
||||
|
||||
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
var fadeAnimation = animation.drive(fadeTween);
|
||||
|
||||
return SlideTransition(
|
||||
position: offsetAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 스케일 + 페이드 전환
|
||||
class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final Alignment alignment;
|
||||
|
||||
ScalePageRoute({
|
||||
required this.page,
|
||||
this.alignment = Alignment.center,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.elasticOut;
|
||||
|
||||
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
var scaleAnimation = animation.drive(scaleTween);
|
||||
|
||||
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||
CurveTween(curve: Curves.easeIn),
|
||||
);
|
||||
var fadeAnimation = animation.drive(fadeTween);
|
||||
|
||||
return ScaleTransition(
|
||||
scale: scaleAnimation,
|
||||
alignment: alignment,
|
||||
child: FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 회전 + 스케일 전환
|
||||
class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
|
||||
RotatePageRoute({required this.page})
|
||||
: super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.easeInOut;
|
||||
|
||||
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
var rotateAnimation = animation.drive(rotateTween);
|
||||
|
||||
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
var scaleAnimation = animation.drive(scaleTween);
|
||||
|
||||
return Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateZ(rotateAnimation.value)
|
||||
..scale(scaleAnimation.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 3D 플립 전환
|
||||
class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final bool horizontal;
|
||||
|
||||
FlipPageRoute({
|
||||
required this.page,
|
||||
this.horizontal = true,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 800),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final isAnimatingForward = animation.status == AnimationStatus.forward;
|
||||
|
||||
final flipAnimation = Tween(
|
||||
begin: 0.0,
|
||||
end: isAnimatingForward ? -math.pi : math.pi,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flipAnimation,
|
||||
builder: (context, child) {
|
||||
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
|
||||
|
||||
return Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(horizontal ? flipAnimation.value : 0)
|
||||
..rotateX(horizontal ? 0 : flipAnimation.value),
|
||||
child: isShowingFront
|
||||
? Container()
|
||||
: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..rotateY(horizontal ? math.pi : 0)
|
||||
..rotateX(horizontal ? 0 : math.pi),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 컨테이너 트랜스폼 (Material Design)
|
||||
class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final Widget startWidget;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
ContainerTransformPageRoute({
|
||||
required this.page,
|
||||
required this.startWidget,
|
||||
this.borderRadius,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 배경 페이드
|
||||
FadeTransition(
|
||||
opacity: animation,
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
// 컨테이너 확장 애니메이션
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final progress = animation.value;
|
||||
final scale = 0.5 + (0.5 * progress);
|
||||
final radius = borderRadius?.topLeft.x ?? 0;
|
||||
final currentRadius = radius * (1 - progress);
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(currentRadius),
|
||||
child: progress < 0.5 ? startWidget : child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 커스텀 Hero 애니메이션
|
||||
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final String heroTag;
|
||||
|
||||
CustomHeroPageRoute({
|
||||
required this.page,
|
||||
required this.heroTag,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: const Interval(0.5, 1.0),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 공유 축 전환 (Material Design)
|
||||
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
||||
final Widget page;
|
||||
final SharedAxisTransitionType transitionType;
|
||||
|
||||
SharedAxisPageRoute({
|
||||
required this.page,
|
||||
required this.transitionType,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
late final Offset begin;
|
||||
late final Offset end;
|
||||
|
||||
switch (transitionType) {
|
||||
case SharedAxisTransitionType.horizontal:
|
||||
begin = const Offset(1.0, 0.0);
|
||||
end = Offset.zero;
|
||||
break;
|
||||
case SharedAxisTransitionType.vertical:
|
||||
begin = const Offset(0.0, 1.0);
|
||||
end = Offset.zero;
|
||||
break;
|
||||
case SharedAxisTransitionType.scaled:
|
||||
begin = Offset.zero;
|
||||
end = Offset.zero;
|
||||
break;
|
||||
}
|
||||
|
||||
final slideTween = Tween(begin: begin, end: end);
|
||||
final fadeTween = Tween(begin: 0.0, end: 1.0);
|
||||
final scaleTween = transitionType == SharedAxisTransitionType.scaled
|
||||
? Tween(begin: 0.8, end: 1.0)
|
||||
: Tween(begin: 1.0, end: 1.0);
|
||||
|
||||
final slideAnimation = animation.drive(slideTween);
|
||||
final fadeAnimation = animation.drive(fadeTween);
|
||||
final scaleAnimation = animation.drive(scaleTween);
|
||||
|
||||
return SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: scaleAnimation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
enum SharedAxisTransitionType {
|
||||
horizontal,
|
||||
vertical,
|
||||
scaled,
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
),
|
||||
@@ -64,7 +64,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(110),
|
||||
),
|
||||
),
|
||||
@@ -90,7 +90,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
),
|
||||
@@ -109,7 +109,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
color: Colors.white.withValues(alpha:
|
||||
0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
|
||||
200
lib/widgets/app_navigator.dart
Normal file
200
lib/widgets/app_navigator.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../screens/main_screen.dart';
|
||||
import '../screens/analysis_screen.dart';
|
||||
import '../screens/add_subscription_screen.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import '../screens/settings_screen.dart';
|
||||
import '../screens/sms_scan_screen.dart';
|
||||
import '../screens/category_management_screen.dart';
|
||||
import '../screens/app_lock_screen.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import 'animated_page_transitions.dart';
|
||||
|
||||
/// 앱 전체의 네비게이션을 관리하는 클래스
|
||||
class AppNavigator {
|
||||
// NavigationProvider를 사용하여 상태를 관리하므로 더 이상 싱글톤 패턴이 필요하지 않음
|
||||
|
||||
/// 홈으로 네비게이션
|
||||
static Future<void> toHome(BuildContext context) async {
|
||||
HapticFeedback.lightImpact();
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
navigationProvider.clearHistoryAndGoHome();
|
||||
|
||||
await Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
AppRoutes.main,
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 분석 화면으로 네비게이션
|
||||
static Future<void> toAnalysis(BuildContext context) async {
|
||||
HapticFeedback.lightImpact();
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
navigationProvider.updateCurrentIndex(1);
|
||||
|
||||
await Navigator.of(context).pushNamed(AppRoutes.analysis);
|
||||
}
|
||||
|
||||
/// 구독 추가 화면으로 네비게이션
|
||||
static Future<void> toAddSubscription(BuildContext context) async {
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
|
||||
}
|
||||
|
||||
/// 구독 상세 화면으로 네비게이션
|
||||
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
await Navigator.of(context).pushNamed(
|
||||
AppRoutes.subscriptionDetail,
|
||||
arguments: subscription,
|
||||
);
|
||||
}
|
||||
|
||||
/// SMS 스캔 화면으로 네비게이션
|
||||
static Future<void> toSmsScan(BuildContext context) async {
|
||||
HapticFeedback.lightImpact();
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
navigationProvider.updateCurrentIndex(3);
|
||||
|
||||
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
|
||||
}
|
||||
|
||||
/// 설정 화면으로 네비게이션
|
||||
static Future<void> toSettings(BuildContext context) async {
|
||||
HapticFeedback.lightImpact();
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
navigationProvider.updateCurrentIndex(4);
|
||||
|
||||
await Navigator.of(context).pushNamed(AppRoutes.settings);
|
||||
}
|
||||
|
||||
/// 카테고리 관리 화면으로 네비게이션
|
||||
static Future<void> toCategoryManagement(BuildContext context) async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
await Navigator.of(context).push(
|
||||
SlidePageRoute(
|
||||
page: const CategoryManagementScreen(),
|
||||
direction: AxisDirection.up,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 앱 잠금 화면으로 네비게이션
|
||||
static Future<void> toAppLock(BuildContext context) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const AppLockScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 뒤로가기 처리
|
||||
static Future<bool> handleBackButton(BuildContext context) async {
|
||||
final navigator = Navigator.of(context);
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
|
||||
// 네비게이션 스택이 있으면 팝
|
||||
if (navigator.canPop()) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
|
||||
if (navigationProvider.canPop()) {
|
||||
navigationProvider.pop();
|
||||
}
|
||||
|
||||
navigator.pop();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 앱 종료 확인
|
||||
final shouldExit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('앱 종료'),
|
||||
content: const Text('SubManager를 종료하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('종료'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return shouldExit ?? false;
|
||||
}
|
||||
|
||||
/// 플로팅 네비게이션 바 탭 처리
|
||||
static void handleFloatingNavTap(BuildContext context, int index) {
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
final currentIndex = navigationProvider.currentIndex;
|
||||
|
||||
// 같은 탭을 다시 탭하면 아무 동작 안 함
|
||||
if (currentIndex == index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
// 선택된 인덱스에 따라 네비게이션
|
||||
switch (index) {
|
||||
case 0: // 홈
|
||||
navigationProvider.updateCurrentIndex(0);
|
||||
break;
|
||||
case 1: // 분석
|
||||
toAnalysis(context);
|
||||
break;
|
||||
case 2: // 추가
|
||||
toAddSubscription(context);
|
||||
break;
|
||||
case 3: // SMS
|
||||
toSmsScan(context);
|
||||
break;
|
||||
case 4: // 설정
|
||||
toSettings(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 네비게이션 관찰자 (디버깅용)
|
||||
class AppNavigationObserver extends NavigatorObserver {
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
debugPrint('Navigation: Push ${route.settings.name}');
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
debugPrint('Navigation: Pop ${route.settings.name}');
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
super.didRemove(route, previousRoute);
|
||||
debugPrint('Navigation: Remove ${route.settings.name}');
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||
}
|
||||
}
|
||||
315
lib/widgets/cached_network_image_widget.dart
Normal file
315
lib/widgets/cached_network_image_widget.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'skeleton_loading.dart';
|
||||
|
||||
/// 최적화된 캐시 네트워크 이미지 위젯
|
||||
class OptimizedCachedNetworkImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius? borderRadius;
|
||||
final Duration fadeInDuration;
|
||||
final Duration fadeOutDuration;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final bool enableMemoryCache;
|
||||
final bool enableDiskCache;
|
||||
final int? maxWidth;
|
||||
final int? maxHeight;
|
||||
|
||||
const OptimizedCachedNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius,
|
||||
this.fadeInDuration = const Duration(milliseconds: 300),
|
||||
this.fadeOutDuration = const Duration(milliseconds: 300),
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.httpHeaders,
|
||||
this.enableMemoryCache = true,
|
||||
this.enableDiskCache = true,
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 성능 최적화를 위한 이미지 크기 계산
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final optimalWidth = maxWidth ??
|
||||
(width != null ? (width! * devicePixelRatio).round() : null);
|
||||
final optimalHeight = maxHeight ??
|
||||
(height != null ? (height! * devicePixelRatio).round() : null);
|
||||
|
||||
Widget image = CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
fadeInDuration: fadeInDuration,
|
||||
fadeOutDuration: fadeOutDuration,
|
||||
httpHeaders: httpHeaders,
|
||||
memCacheWidth: optimalWidth,
|
||||
memCacheHeight: optimalHeight,
|
||||
maxWidthDiskCache: optimalWidth,
|
||||
maxHeightDiskCache: optimalHeight,
|
||||
placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(),
|
||||
errorWidget: (context, url, error) =>
|
||||
errorWidget ?? _buildDefaultErrorWidget(),
|
||||
imageBuilder: (context, imageProvider) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
image: DecorationImage(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (borderRadius != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius!,
|
||||
child: image,
|
||||
);
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
Widget _buildDefaultPlaceholder() {
|
||||
return SkeletonLoading(
|
||||
width: width,
|
||||
height: height,
|
||||
borderRadius: borderRadius?.topLeft.x ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultErrorWidget() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: AppColors.textMuted,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프로그레시브 이미지 로더 (저화질 → 고화질)
|
||||
class ProgressiveNetworkImage extends StatelessWidget {
|
||||
final String thumbnailUrl;
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const ProgressiveNetworkImage({
|
||||
super.key,
|
||||
required this.thumbnailUrl,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
// 썸네일 (저화질)
|
||||
OptimizedCachedNetworkImage(
|
||||
imageUrl: thumbnailUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
borderRadius: borderRadius,
|
||||
fadeInDuration: Duration.zero,
|
||||
),
|
||||
// 원본 이미지 (고화질)
|
||||
OptimizedCachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미지 갤러리 위젯 (메모리 효율적)
|
||||
class OptimizedImageGallery extends StatefulWidget {
|
||||
final List<String> imageUrls;
|
||||
final double itemHeight;
|
||||
final double spacing;
|
||||
final int crossAxisCount;
|
||||
final void Function(int)? onImageTap;
|
||||
|
||||
const OptimizedImageGallery({
|
||||
super.key,
|
||||
required this.imageUrls,
|
||||
this.itemHeight = 120,
|
||||
this.spacing = 8,
|
||||
this.crossAxisCount = 3,
|
||||
this.onImageTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OptimizedImageGallery> createState() => _OptimizedImageGalleryState();
|
||||
}
|
||||
|
||||
class _OptimizedImageGalleryState extends State<OptimizedImageGallery> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final Set<int> _visibleIndices = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
// 초기 보이는 아이템 계산
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_calculateVisibleIndices();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
_calculateVisibleIndices();
|
||||
}
|
||||
|
||||
void _calculateVisibleIndices() {
|
||||
if (!mounted) return;
|
||||
|
||||
final viewportHeight = context.size?.height ?? 0;
|
||||
final scrollOffset = _scrollController.offset;
|
||||
final itemHeight = widget.itemHeight + widget.spacing;
|
||||
final itemsPerRow = widget.crossAxisCount;
|
||||
|
||||
final firstVisibleRow = (scrollOffset / itemHeight).floor();
|
||||
final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil();
|
||||
|
||||
final newVisibleIndices = <int>{};
|
||||
for (int row = firstVisibleRow; row <= lastVisibleRow; row++) {
|
||||
for (int col = 0; col < itemsPerRow; col++) {
|
||||
final index = row * itemsPerRow + col;
|
||||
if (index < widget.imageUrls.length) {
|
||||
newVisibleIndices.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!setEquals(_visibleIndices, newVisibleIndices)) {
|
||||
setState(() {
|
||||
_visibleIndices.clear();
|
||||
_visibleIndices.addAll(newVisibleIndices);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
controller: _scrollController,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.crossAxisCount,
|
||||
childAspectRatio: 1.0,
|
||||
crossAxisSpacing: widget.spacing,
|
||||
mainAxisSpacing: widget.spacing,
|
||||
),
|
||||
itemCount: widget.imageUrls.length,
|
||||
itemBuilder: (context, index) {
|
||||
// 보이는 영역의 이미지만 로드
|
||||
if (_visibleIndices.contains(index) ||
|
||||
(index >= _visibleIndices.first - widget.crossAxisCount &&
|
||||
index <= _visibleIndices.last + widget.crossAxisCount)) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onImageTap?.call(index),
|
||||
child: OptimizedCachedNetworkImage(
|
||||
imageUrl: widget.imageUrls[index],
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 보이지 않는 영역은 플레이스홀더
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool setEquals(Set<int> a, Set<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final item in a) {
|
||||
if (!b.contains(item)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 히어로 애니메이션이 적용된 이미지
|
||||
class HeroNetworkImage extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
final String heroTag;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const HeroNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: OptimizedCachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'glassmorphism_card.dart';
|
||||
import 'themed_text.dart';
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
@@ -31,21 +33,10 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: Container(
|
||||
child: GlassmorphismCard(
|
||||
width: null,
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -65,7 +56,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
||||
color: const Color(0xFF3B82F6).withValues(alpha: 0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -82,29 +73,17 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: const Text(
|
||||
'등록된 구독이 없습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const ThemedText(
|
||||
'등록된 구독이 없습니다',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'새로운 구독을 추가해보세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
MouseRegion(
|
||||
@@ -133,6 +112,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
268
lib/widgets/expandable_fab.dart
Normal file
268
lib/widgets/expandable_fab.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
310
lib/widgets/floating_navigation_bar.dart
Normal file
310
lib/widgets/floating_navigation_bar.dart
Normal file
@@ -0,0 +1,310 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class FloatingNavigationBar extends StatefulWidget {
|
||||
final int selectedIndex;
|
||||
final Function(int) onItemTapped;
|
||||
final bool isVisible;
|
||||
|
||||
const FloatingNavigationBar({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
required this.onItemTapped,
|
||||
this.isVisible = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FloatingNavigationBar> createState() => _FloatingNavigationBarState();
|
||||
}
|
||||
|
||||
class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
if (widget.isVisible) {
|
||||
_controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FloatingNavigationBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isVisible != oldWidget.isVisible) {
|
||||
if (widget.isVisible) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||
child: Opacity(
|
||||
opacity: _animation.value,
|
||||
child: GlassmorphismCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
borderRadius: 24,
|
||||
blur: 10.0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_NavigationItem(
|
||||
icon: Icons.home_rounded,
|
||||
label: '홈',
|
||||
isSelected: widget.selectedIndex == 0,
|
||||
onTap: () => _onItemTapped(0),
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.analytics_rounded,
|
||||
label: '분석',
|
||||
isSelected: widget.selectedIndex == 1,
|
||||
onTap: () => _onItemTapped(1),
|
||||
),
|
||||
_AddButton(
|
||||
onTap: () => _onItemTapped(2),
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.qr_code_scanner_rounded,
|
||||
label: 'SMS',
|
||||
isSelected: widget.selectedIndex == 3,
|
||||
onTap: () => _onItemTapped(3),
|
||||
),
|
||||
_NavigationItem(
|
||||
icon: Icons.settings_rounded,
|
||||
label: '설정',
|
||||
isSelected: widget.selectedIndex == 4,
|
||||
onTap: () => _onItemTapped(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
HapticFeedback.lightImpact();
|
||||
widget.onItemTapped(index);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavigationItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF14B8A6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? const Color(0xFF14B8A6)
|
||||
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
|
||||
size: isSelected ? 26 : 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? const Color(0xFF14B8A6)
|
||||
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
|
||||
),
|
||||
child: Text(label),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatefulWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AddButton({required this.onTap});
|
||||
|
||||
@override
|
||||
State<_AddButton> createState() => _AddButtonState();
|
||||
}
|
||||
|
||||
class _AddButtonState extends State<_AddButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.9,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: (_) => _controller.forward(),
|
||||
onTapUp: (_) {
|
||||
_controller.reverse();
|
||||
widget.onTap();
|
||||
},
|
||||
onTapCancel: () => _controller.reverse(),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppColors.blueGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 스크롤 감지를 위한 유틸리티 클래스
|
||||
class FloatingNavBarScrollController {
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onHide;
|
||||
final VoidCallback onShow;
|
||||
|
||||
double _lastScrollPosition = 0;
|
||||
bool _isVisible = true;
|
||||
|
||||
FloatingNavBarScrollController({
|
||||
required this.scrollController,
|
||||
required this.onHide,
|
||||
required this.onShow,
|
||||
}) {
|
||||
scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
final currentScroll = scrollController.position.pixels;
|
||||
|
||||
if (currentScroll > _lastScrollPosition && currentScroll > 50) {
|
||||
// 스크롤 다운
|
||||
if (_isVisible) {
|
||||
_isVisible = false;
|
||||
onHide();
|
||||
}
|
||||
} else if (currentScroll < _lastScrollPosition - 5) {
|
||||
// 스크롤 업
|
||||
if (!_isVisible) {
|
||||
_isVisible = true;
|
||||
onShow();
|
||||
}
|
||||
}
|
||||
|
||||
_lastScrollPosition = currentScroll;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
scrollController.removeListener(_handleScroll);
|
||||
}
|
||||
}
|
||||
304
lib/widgets/glassmorphic_app_bar.dart
Normal file
304
lib/widgets/glassmorphic_app_bar.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'themed_text.dart';
|
||||
|
||||
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
||||
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double elevation;
|
||||
final Color? backgroundColor;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final bool centerTitle;
|
||||
final double? titleSpacing;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const GlassmorphicAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation = 0,
|
||||
this.backgroundColor,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.bottom,
|
||||
this.centerTitle = false,
|
||||
this.titleSpacing,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(backgroundColor ?? (isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground)).withValues(alpha: opacity),
|
||||
(backgroundColor ?? (isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.glassBorderDark.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: NavigationToolbar(
|
||||
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||
? _buildBackButton(context)
|
||||
: null),
|
||||
middle: _buildTitle(context),
|
||||
trailing: actions != null
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions!,
|
||||
)
|
||||
: null,
|
||||
centerMiddle: centerTitle,
|
||||
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ?? () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: '뒤로가기',
|
||||
color: ThemedText.getContrastColor(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 투명 스타일 팩토리
|
||||
static GlassmorphicAppBar transparent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 30,
|
||||
opacity: 0.05,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 반투명 스타일 팩토리
|
||||
static GlassmorphicAppBar translucent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 20,
|
||||
opacity: 0.15,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
|
||||
class GlassmorphicSliverAppBar extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final double expandedHeight;
|
||||
final bool floating;
|
||||
final bool pinned;
|
||||
final bool snap;
|
||||
final Widget? flexibleSpace;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final bool automaticallyImplyLeading;
|
||||
final VoidCallback? onBackPressed;
|
||||
final bool centerTitle;
|
||||
|
||||
const GlassmorphicSliverAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.expandedHeight = kToolbarHeight,
|
||||
this.floating = false,
|
||||
this.pinned = true,
|
||||
this.snap = false,
|
||||
this.flexibleSpace,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
this.centerTitle = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
floating: floating,
|
||||
pinned: pinned,
|
||||
snap: snap,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ?? () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: '뒤로가기',
|
||||
)
|
||||
: null),
|
||||
actions: actions,
|
||||
centerTitle: centerTitle,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final top = constraints.biggest.height;
|
||||
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
title: isCollapsed
|
||||
? ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
centerTitle: centerTitle,
|
||||
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 글래스모피즘 배경
|
||||
ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground).withValues(alpha: opacity),
|
||||
(isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface).withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.glassBorderDark.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 확장 상태에서만 보이는 타이틀
|
||||
if (!isCollapsed)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 커스텀 flexibleSpace가 있으면 추가
|
||||
if (flexibleSpace != null) flexibleSpace!,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
314
lib/widgets/glassmorphic_scaffold.dart
Normal file
314
lib/widgets/glassmorphic_scaffold.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphic_app_bar.dart';
|
||||
import 'floating_navigation_bar.dart';
|
||||
|
||||
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
|
||||
class GlassmorphicScaffold extends StatefulWidget {
|
||||
final PreferredSizeWidget? appBar;
|
||||
final Widget body;
|
||||
final Widget? floatingActionButton;
|
||||
final FloatingActionButtonLocation? floatingActionButtonLocation;
|
||||
final List<Color>? backgroundGradient;
|
||||
final bool extendBodyBehindAppBar;
|
||||
final bool extendBody;
|
||||
final Widget? bottomNavigationBar;
|
||||
final bool useFloatingNavBar;
|
||||
final int? floatingNavBarIndex;
|
||||
final Function(int)? onFloatingNavBarTapped;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
final Widget? drawer;
|
||||
final Widget? endDrawer;
|
||||
final Color? backgroundColor;
|
||||
final bool enableParticles;
|
||||
final bool enableWaveAnimation;
|
||||
|
||||
const GlassmorphicScaffold({
|
||||
super.key,
|
||||
this.appBar,
|
||||
required this.body,
|
||||
this.floatingActionButton,
|
||||
this.floatingActionButtonLocation,
|
||||
this.backgroundGradient,
|
||||
this.extendBodyBehindAppBar = true,
|
||||
this.extendBody = true,
|
||||
this.bottomNavigationBar,
|
||||
this.useFloatingNavBar = false,
|
||||
this.floatingNavBarIndex,
|
||||
this.onFloatingNavBarTapped,
|
||||
this.resizeToAvoidBottomInset = true,
|
||||
this.drawer,
|
||||
this.endDrawer,
|
||||
this.backgroundColor,
|
||||
this.enableParticles = false,
|
||||
this.enableWaveAnimation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
|
||||
}
|
||||
|
||||
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _particleController;
|
||||
late AnimationController _waveController;
|
||||
ScrollController? _scrollController;
|
||||
bool _isFloatingNavBarVisible = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_particleController = AnimationController(
|
||||
duration: const Duration(seconds: 20),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
_waveController = AnimationController(
|
||||
duration: const Duration(seconds: 10),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
if (widget.useFloatingNavBar) {
|
||||
_scrollController = ScrollController();
|
||||
_setupScrollListener();
|
||||
}
|
||||
}
|
||||
|
||||
void _setupScrollListener() {
|
||||
_scrollController?.addListener(() {
|
||||
final currentScroll = _scrollController!.position.pixels;
|
||||
final maxScroll = _scrollController!.position.maxScrollExtent;
|
||||
|
||||
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
|
||||
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
|
||||
if (_isFloatingNavBarVisible) {
|
||||
setState(() => _isFloatingNavBarVisible = false);
|
||||
}
|
||||
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
|
||||
if (!_isFloatingNavBarVisible) {
|
||||
setState(() => _isFloatingNavBarVisible = true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_particleController.dispose();
|
||||
_waveController.dispose();
|
||||
_scrollController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Color> _getBackgroundGradient() {
|
||||
if (widget.backgroundGradient != null) {
|
||||
return widget.backgroundGradient!;
|
||||
}
|
||||
|
||||
// 시간대별 기본 그라디언트
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour >= 6 && hour < 10) {
|
||||
return AppColors.morningGradient;
|
||||
} else if (hour >= 10 && hour < 17) {
|
||||
return AppColors.dayGradient;
|
||||
} else if (hour >= 17 && hour < 20) {
|
||||
return AppColors.eveningGradient;
|
||||
} else {
|
||||
return AppColors.nightGradient;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundGradient = _getBackgroundGradient();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 배경 그라디언트
|
||||
_buildBackground(backgroundGradient),
|
||||
|
||||
// 파티클 효과 (선택적)
|
||||
if (widget.enableParticles) _buildParticles(),
|
||||
|
||||
// 웨이브 애니메이션 (선택적)
|
||||
if (widget.enableWaveAnimation) _buildWaveAnimation(),
|
||||
|
||||
// 메인 스캐폴드
|
||||
Scaffold(
|
||||
backgroundColor: widget.backgroundColor ?? Colors.transparent,
|
||||
appBar: widget.appBar,
|
||||
body: widget.body,
|
||||
floatingActionButton: widget.floatingActionButton,
|
||||
floatingActionButtonLocation: widget.floatingActionButtonLocation,
|
||||
bottomNavigationBar: widget.bottomNavigationBar,
|
||||
extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
|
||||
extendBody: widget.extendBody,
|
||||
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
|
||||
drawer: widget.drawer,
|
||||
endDrawer: widget.endDrawer,
|
||||
),
|
||||
|
||||
// 플로팅 네비게이션 바 (선택적)
|
||||
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
|
||||
FloatingNavigationBar(
|
||||
selectedIndex: widget.floatingNavBarIndex!,
|
||||
isVisible: _isFloatingNavBarVisible,
|
||||
onItemTapped: widget.onFloatingNavBarTapped ?? (_) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackground(List<Color> gradientColors) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: gradientColors.map((color) => color.withValues(alpha: 0.1)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildParticles() {
|
||||
return Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _particleController,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: ParticlePainter(
|
||||
animation: _particleController,
|
||||
particleCount: 30,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveAnimation() {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 200,
|
||||
child: AnimatedBuilder(
|
||||
animation: _waveController,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: WavePainter(
|
||||
animation: _waveController,
|
||||
waveColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 파티클 페인터
|
||||
class ParticlePainter extends CustomPainter {
|
||||
final Animation<double> animation;
|
||||
final int particleCount;
|
||||
final List<Particle> particles = [];
|
||||
|
||||
ParticlePainter({
|
||||
required this.animation,
|
||||
this.particleCount = 50,
|
||||
}) : super(repaint: animation) {
|
||||
_initParticles();
|
||||
}
|
||||
|
||||
void _initParticles() {
|
||||
final random = math.Random();
|
||||
for (int i = 0; i < particleCount; i++) {
|
||||
particles.add(Particle(
|
||||
x: random.nextDouble(),
|
||||
y: random.nextDouble(),
|
||||
size: random.nextDouble() * 3 + 1,
|
||||
speed: random.nextDouble() * 0.5 + 0.1,
|
||||
opacity: random.nextDouble() * 0.5 + 0.1,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..style = PaintingStyle.fill;
|
||||
|
||||
for (final particle in particles) {
|
||||
final progress = animation.value;
|
||||
final y = (particle.y + progress * particle.speed) % 1.0;
|
||||
|
||||
paint.color = Colors.white.withValues(alpha: particle.opacity);
|
||||
canvas.drawCircle(
|
||||
Offset(particle.x * size.width, y * size.height),
|
||||
particle.size,
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
/// 웨이브 페인터
|
||||
class WavePainter extends CustomPainter {
|
||||
final Animation<double> animation;
|
||||
final Color waveColor;
|
||||
|
||||
WavePainter({
|
||||
required this.animation,
|
||||
required this.waveColor,
|
||||
}) : super(repaint: animation);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = waveColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path();
|
||||
final progress = animation.value;
|
||||
|
||||
path.moveTo(0, size.height);
|
||||
|
||||
for (double x = 0; x <= size.width; x++) {
|
||||
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
|
||||
size.height * 0.5;
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
|
||||
path.lineTo(size.width, size.height);
|
||||
path.close();
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
/// 파티클 데이터 클래스
|
||||
class Particle {
|
||||
final double x;
|
||||
final double y;
|
||||
final double size;
|
||||
final double speed;
|
||||
final double opacity;
|
||||
|
||||
Particle({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.size,
|
||||
required this.speed,
|
||||
required this.opacity,
|
||||
});
|
||||
}
|
||||
210
lib/widgets/glassmorphism_card.dart
Normal file
210
lib/widgets/glassmorphism_card.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class GlassmorphismCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final Color? backgroundColor;
|
||||
final Gradient? gradient;
|
||||
final Border? border;
|
||||
final List<BoxShadow>? boxShadow;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const GlassmorphismCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 16.0,
|
||||
this.blur = 10.0,
|
||||
this.opacity = 0.1,
|
||||
this.backgroundColor,
|
||||
this.gradient,
|
||||
this.border,
|
||||
this.boxShadow,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? (isDarkMode
|
||||
? AppColors.glassCardDark
|
||||
: AppColors.glassCard),
|
||||
gradient: gradient ?? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDarkMode
|
||||
? AppColors.glassGradientDark
|
||||
: AppColors.glassGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: border ?? Border.all(
|
||||
color: isDarkMode
|
||||
? AppColors.glassBorderDark
|
||||
: AppColors.glassBorder,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: boxShadow ?? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: -5,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 애니메이션이 적용된 글래스모피즘 카드
|
||||
class AnimatedGlassmorphismCard extends StatefulWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final Duration animationDuration;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const AnimatedGlassmorphismCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 16.0,
|
||||
this.blur = 10.0,
|
||||
this.opacity = 0.1,
|
||||
this.animationDuration = const Duration(milliseconds: 200),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
|
||||
}
|
||||
|
||||
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _blurAnimation;
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.animationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.98,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_blurAnimation = Tween<double>(
|
||||
begin: widget.blur,
|
||||
end: widget.blur * 1.5,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
setState(() {
|
||||
_isPressed = true;
|
||||
});
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
setState(() {
|
||||
_isPressed = false;
|
||||
});
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
setState(() {
|
||||
_isPressed = false;
|
||||
});
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: GlassmorphismCard(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
borderRadius: widget.borderRadius,
|
||||
blur: _blurAnimation.value,
|
||||
opacity: widget.opacity,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/widgets/home_content.dart
Normal file
154
lib/widgets/home_content.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../utils/subscription_category_helper.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
||||
class HomeContent extends StatelessWidget {
|
||||
final AnimationController fadeController;
|
||||
final AnimationController rotateController;
|
||||
final AnimationController slideController;
|
||||
final AnimationController pulseController;
|
||||
final AnimationController waveController;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onAddPressed;
|
||||
|
||||
const HomeContent({
|
||||
super.key,
|
||||
required this.fadeController,
|
||||
required this.rotateController,
|
||||
required this.slideController,
|
||||
required this.pulseController,
|
||||
required this.waveController,
|
||||
required this.scrollController,
|
||||
required this.onAddPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<SubscriptionProvider>();
|
||||
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.subscriptions.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
fadeController: fadeController,
|
||||
rotateController: rotateController,
|
||||
slideController: slideController,
|
||||
onAddPressed: onAddPressed,
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리별 구독 구분
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
provider.subscriptions,
|
||||
categoryProvider,
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await provider.refreshSubscriptions();
|
||||
},
|
||||
color: const Color(0xFF3B82F6),
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
const GlassmorphicSliverAppBar(
|
||||
title: '홈',
|
||||
pinned: true,
|
||||
expandedHeight: kToolbarHeight,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: const ValueKey('home_ad')),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutCubic)),
|
||||
child: MainScreenSummaryCard(
|
||||
provider: provider,
|
||||
fadeController: fadeController,
|
||||
pulseController: pulseController,
|
||||
waveController: waveController,
|
||||
slideController: slideController,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(-0.2, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutCubic)),
|
||||
child: Text(
|
||||
'나의 구독 서비스',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.2, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutCubic)),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${provider.subscriptions.length}개',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SubscriptionListWidget(
|
||||
categorizedSubscriptions: categorizedSubscriptions,
|
||||
fadeController: fadeController,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
416
lib/widgets/lazy_loading_list.dart
Normal file
416
lib/widgets/lazy_loading_list.dart
Normal file
@@ -0,0 +1,416 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import '../utils/performance_optimizer.dart';
|
||||
import '../widgets/skeleton_loading.dart';
|
||||
|
||||
/// 레이지 로딩이 적용된 리스트 위젯
|
||||
class LazyLoadingList<T> extends StatefulWidget {
|
||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||
final int pageSize;
|
||||
final double scrollThreshold;
|
||||
final Widget? loadingWidget;
|
||||
final Widget? emptyWidget;
|
||||
final Widget? errorWidget;
|
||||
final bool enableRefresh;
|
||||
final ScrollPhysics? physics;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const LazyLoadingList({
|
||||
super.key,
|
||||
required this.loadMore,
|
||||
required this.itemBuilder,
|
||||
this.pageSize = 20,
|
||||
this.scrollThreshold = 0.8,
|
||||
this.loadingWidget,
|
||||
this.emptyWidget,
|
||||
this.errorWidget,
|
||||
this.enableRefresh = true,
|
||||
this.physics,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LazyLoadingList<T>> createState() => _LazyLoadingListState<T>();
|
||||
}
|
||||
|
||||
class _LazyLoadingListState<T> extends State<LazyLoadingList<T>> {
|
||||
final List<T> _items = [];
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
int _currentPage = 0;
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialData();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
|
||||
final position = _scrollController.position;
|
||||
final maxScroll = position.maxScrollExtent;
|
||||
final currentScroll = position.pixels;
|
||||
|
||||
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
||||
_loadMoreData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final newItems = await PerformanceMeasure.measure(
|
||||
name: 'Initial data load',
|
||||
operation: () => widget.loadMore(0, widget.pageSize),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_items.clear();
|
||||
_items.addAll(newItems);
|
||||
_currentPage = 0;
|
||||
_hasMore = newItems.length >= widget.pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreData() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final nextPage = _currentPage + 1;
|
||||
final newItems = await PerformanceMeasure.measure(
|
||||
name: 'Load more data (page $nextPage)',
|
||||
operation: () => widget.loadMore(nextPage, widget.pageSize),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_items.addAll(newItems);
|
||||
_currentPage = nextPage;
|
||||
_hasMore = newItems.length >= widget.pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadInitialData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null && _items.isEmpty) {
|
||||
return Center(
|
||||
child: widget.errorWidget ?? _buildDefaultErrorWidget(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_isLoading && _items.isEmpty) {
|
||||
return Center(
|
||||
child: widget.emptyWidget ?? _buildDefaultEmptyWidget(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget listView = ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(),
|
||||
padding: widget.padding,
|
||||
itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _items.length) {
|
||||
return widget.itemBuilder(context, _items[index], index);
|
||||
}
|
||||
|
||||
// 로딩 인디케이터
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: widget.loadingWidget ?? _buildDefaultLoadingWidget(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.enableRefresh) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: listView,
|
||||
);
|
||||
}
|
||||
|
||||
return listView;
|
||||
}
|
||||
|
||||
Widget _buildDefaultLoadingWidget() {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
|
||||
Widget _buildDefaultEmptyWidget() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'데이터가 없습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultErrorWidget() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'오류가 발생했습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadInitialData,
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 캐시가 적용된 레이지 로딩 리스트
|
||||
class CachedLazyLoadingList<T> extends StatefulWidget {
|
||||
final String cacheKey;
|
||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||
final int pageSize;
|
||||
final Duration cacheDuration;
|
||||
final Widget? loadingWidget;
|
||||
final Widget? emptyWidget;
|
||||
|
||||
const CachedLazyLoadingList({
|
||||
super.key,
|
||||
required this.cacheKey,
|
||||
required this.loadMore,
|
||||
required this.itemBuilder,
|
||||
this.pageSize = 20,
|
||||
this.cacheDuration = const Duration(minutes: 5),
|
||||
this.loadingWidget,
|
||||
this.emptyWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CachedLazyLoadingList<T>> createState() => _CachedLazyLoadingListState<T>();
|
||||
}
|
||||
|
||||
class _CachedLazyLoadingListState<T> extends State<CachedLazyLoadingList<T>> {
|
||||
final Map<int, List<T>> _pageCache = {};
|
||||
|
||||
Future<List<T>> _loadWithCache(int page, int pageSize) async {
|
||||
// 캐시 확인
|
||||
if (_pageCache.containsKey(page)) {
|
||||
return _pageCache[page]!;
|
||||
}
|
||||
|
||||
// 데이터 로드
|
||||
final items = await widget.loadMore(page, pageSize);
|
||||
|
||||
// 캐시 저장
|
||||
_pageCache[page] = items;
|
||||
|
||||
// 일정 시간 후 캐시 제거
|
||||
Timer(widget.cacheDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pageCache.remove(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LazyLoadingList<T>(
|
||||
loadMore: _loadWithCache,
|
||||
itemBuilder: widget.itemBuilder,
|
||||
pageSize: widget.pageSize,
|
||||
loadingWidget: widget.loadingWidget,
|
||||
emptyWidget: widget.emptyWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 무한 스크롤 그리드 뷰
|
||||
class LazyLoadingGrid<T> extends StatefulWidget {
|
||||
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||
final int crossAxisCount;
|
||||
final int pageSize;
|
||||
final double scrollThreshold;
|
||||
final double childAspectRatio;
|
||||
final double crossAxisSpacing;
|
||||
final double mainAxisSpacing;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const LazyLoadingGrid({
|
||||
super.key,
|
||||
required this.loadMore,
|
||||
required this.itemBuilder,
|
||||
required this.crossAxisCount,
|
||||
this.pageSize = 20,
|
||||
this.scrollThreshold = 0.8,
|
||||
this.childAspectRatio = 1.0,
|
||||
this.crossAxisSpacing = 8.0,
|
||||
this.mainAxisSpacing = 8.0,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LazyLoadingGrid<T>> createState() => _LazyLoadingGridState<T>();
|
||||
}
|
||||
|
||||
class _LazyLoadingGridState<T> extends State<LazyLoadingGrid<T>> {
|
||||
final List<T> _items = [];
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
int _currentPage = 0;
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialData();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
|
||||
final position = _scrollController.position;
|
||||
final maxScroll = position.maxScrollExtent;
|
||||
final currentScroll = position.pixels;
|
||||
|
||||
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
||||
_loadMoreData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final newItems = await widget.loadMore(0, widget.pageSize);
|
||||
|
||||
setState(() {
|
||||
_items.clear();
|
||||
_items.addAll(newItems);
|
||||
_currentPage = 0;
|
||||
_hasMore = newItems.length >= widget.pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreData() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final nextPage = _currentPage + 1;
|
||||
final newItems = await widget.loadMore(nextPage, widget.pageSize);
|
||||
|
||||
setState(() {
|
||||
_items.addAll(newItems);
|
||||
_currentPage = nextPage;
|
||||
_hasMore = newItems.length >= widget.pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
controller: _scrollController,
|
||||
padding: widget.padding,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.crossAxisCount,
|
||||
childAspectRatio: widget.childAspectRatio,
|
||||
crossAxisSpacing: widget.crossAxisSpacing,
|
||||
mainAxisSpacing: widget.mainAxisSpacing,
|
||||
),
|
||||
itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _items.length) {
|
||||
return widget.itemBuilder(context, _items[index], index);
|
||||
}
|
||||
|
||||
// 로딩 스켈레톤
|
||||
return const SkeletonLoading(
|
||||
height: 100,
|
||||
borderRadius: 12,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,17 @@ import '../providers/subscription_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/format_helper.dart';
|
||||
import 'animated_wave_background.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
||||
///
|
||||
/// 총 구독 수와 월별 총 지출을 표시하며, 분석 화면으로 이동하는 기능을 제공합니다.
|
||||
/// 총 구독 수와 월별 총 지출을 표시합니다.
|
||||
class MainScreenSummaryCard extends StatelessWidget {
|
||||
final SubscriptionProvider provider;
|
||||
final AnimationController fadeController;
|
||||
final AnimationController pulseController;
|
||||
final AnimationController waveController;
|
||||
final AnimationController slideController;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MainScreenSummaryCard({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
@@ -24,7 +23,6 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
required this.pulseController,
|
||||
required this.waveController,
|
||||
required this.slideController,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -40,16 +38,20 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
onTap();
|
||||
},
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 24,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
AppColors.primaryColor.withBlue(
|
||||
(AppColors.primaryColor.blue * 1.3)
|
||||
.clamp(0, 255)
|
||||
.toInt()).withValues(alpha: 0.2),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
@@ -59,17 +61,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withBlue(
|
||||
(AppColors.primaryColor.blue * 1.3)
|
||||
.clamp(0, 255)
|
||||
.toInt()),
|
||||
],
|
||||
),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -91,7 +83,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -118,7 +110,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -153,13 +145,13 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.2),
|
||||
Colors.white.withOpacity(0.15),
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -169,7 +161,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -185,7 +177,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -208,7 +200,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -224,20 +216,10 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -249,7 +231,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -258,7 +240,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io' show Platform;
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||
@@ -84,9 +85,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
Widget _buildWebPlaceholder() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -186,9 +188,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: SizedBox(
|
||||
height: 80, // 네이티브 광고 높이 조정
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
const SkeletonLoading({Key? key}) : super(key: key);
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
|
||||
const SkeletonLoading({
|
||||
Key? key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 8.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 단일 스켈레톤 아이템이 요청된 경우
|
||||
if (width != null || height != null) {
|
||||
return _buildSingleSkeleton();
|
||||
}
|
||||
|
||||
// 기본 전체 화면 스켈레톤
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
Card(
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -29,7 +49,6 @@ class SkeletonLoading extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
@@ -37,32 +56,47 @@ class SkeletonLoading extends StatelessWidget {
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Card(
|
||||
return GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
title: Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
padding: const EdgeInsets.all(16),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -72,6 +106,32 @@ class SkeletonLoading extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSingleSkeleton() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.grey[300]!,
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[300]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -79,15 +139,21 @@ class SkeletonLoading extends StatelessWidget {
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
350
lib/widgets/spring_animation_widget.dart
Normal file
350
lib/widgets/spring_animation_widget.dart
Normal file
@@ -0,0 +1,350 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
||||
class SpringAnimationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
final SpringDescription spring;
|
||||
final Offset? initialOffset;
|
||||
final double? initialScale;
|
||||
final double? initialRotation;
|
||||
|
||||
const SpringAnimationWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
this.spring = const SpringDescription(
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
),
|
||||
this.initialOffset,
|
||||
this.initialScale,
|
||||
this.initialRotation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
|
||||
}
|
||||
|
||||
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _offsetAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
// 스프링 시뮬레이션
|
||||
final simulation = SpringSimulation(
|
||||
widget.spring,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// 오프셋 애니메이션
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: widget.initialOffset ?? const Offset(0, 50),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 스케일 애니메이션
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: widget.initialScale ?? 0.5,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 회전 애니메이션
|
||||
_rotationAnimation = Tween<double>(
|
||||
begin: widget.initialRotation ?? 0.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 지연 후 애니메이션 시작
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: _offsetAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotationAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 바운스 효과가 있는 버튼
|
||||
class BouncyButton extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
const BouncyButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.padding,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BouncyButton> createState() => _BouncyButtonState();
|
||||
}
|
||||
|
||||
class _BouncyButtonState extends State<BouncyButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.95,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
padding: widget.padding,
|
||||
decoration: widget.decoration,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 중력 효과 애니메이션
|
||||
class GravityAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double gravity;
|
||||
final double bounceFactor;
|
||||
final double initialVelocity;
|
||||
|
||||
const GravityAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.gravity = 9.8,
|
||||
this.bounceFactor = 0.8,
|
||||
this.initialVelocity = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GravityAnimation> createState() => _GravityAnimationState();
|
||||
}
|
||||
|
||||
class _GravityAnimationState extends State<GravityAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
double _position = 0;
|
||||
double _velocity = 0;
|
||||
double _floor = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_velocity = widget.initialVelocity;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 10),
|
||||
)..addListener(_updatePhysics);
|
||||
|
||||
_controller.repeat();
|
||||
}
|
||||
|
||||
void _updatePhysics() {
|
||||
setState(() {
|
||||
// 속도 업데이트 (중력 적용)
|
||||
_velocity += widget.gravity * 0.016; // 60fps 가정
|
||||
|
||||
// 위치 업데이트
|
||||
_position += _velocity;
|
||||
|
||||
// 바닥 충돌 감지
|
||||
if (_position >= _floor) {
|
||||
_position = _floor;
|
||||
_velocity = -_velocity * widget.bounceFactor;
|
||||
|
||||
// 너무 작은 바운스는 멈춤
|
||||
if (_velocity.abs() < 1) {
|
||||
_velocity = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _position),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물결 효과 애니메이션
|
||||
class RippleAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Color rippleColor;
|
||||
final Duration duration;
|
||||
|
||||
const RippleAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.rippleColor = Colors.blue,
|
||||
this.duration = const Duration(milliseconds: 600),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RippleAnimation> createState() => _RippleAnimationState();
|
||||
}
|
||||
|
||||
class _RippleAnimationState extends State<RippleAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
_controller.forward(from: 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 100 + 200 * _animation.value,
|
||||
height: 100 + 200 * _animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.rippleColor.withValues(alpha:
|
||||
(1 - _animation.value) * 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
widget.child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
302
lib/widgets/staggered_list_animation.dart
Normal file
302
lib/widgets/staggered_list_animation.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 스태거 애니메이션이 적용된 리스트 위젯
|
||||
class StaggeredListAnimation extends StatefulWidget {
|
||||
final List<Widget> children;
|
||||
final Duration itemDelay;
|
||||
final Duration animationDuration;
|
||||
final Curve curve;
|
||||
final Axis direction;
|
||||
|
||||
const StaggeredListAnimation({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.itemDelay = const Duration(milliseconds: 100),
|
||||
this.animationDuration = const Duration(milliseconds: 500),
|
||||
this.curve = Curves.easeOutBack,
|
||||
this.direction = Axis.vertical,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
|
||||
}
|
||||
|
||||
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
|
||||
with TickerProviderStateMixin {
|
||||
final List<AnimationController> _controllers = [];
|
||||
final List<Animation<double>> _fadeAnimations = [];
|
||||
final List<Animation<Offset>> _slideAnimations = [];
|
||||
final List<Animation<double>> _scaleAnimations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_createAnimations();
|
||||
_startAnimations();
|
||||
}
|
||||
|
||||
void _createAnimations() {
|
||||
for (int i = 0; i < widget.children.length; i++) {
|
||||
final controller = AnimationController(
|
||||
duration: widget.animationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
final fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: widget.direction == Axis.vertical
|
||||
? const Offset(0, 0.3)
|
||||
: const Offset(0.3, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
final scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
_controllers.add(controller);
|
||||
_fadeAnimations.add(fadeAnimation);
|
||||
_slideAnimations.add(slideAnimation);
|
||||
_scaleAnimations.add(scaleAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
void _startAnimations() async {
|
||||
for (int i = 0; i < _controllers.length; i++) {
|
||||
await Future.delayed(widget.itemDelay * i);
|
||||
if (mounted) {
|
||||
_controllers[i].forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.direction == Axis.vertical
|
||||
? Column(
|
||||
children: _buildAnimatedChildren(),
|
||||
)
|
||||
: Row(
|
||||
children: _buildAnimatedChildren(),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildAnimatedChildren() {
|
||||
return List.generate(widget.children.length, (index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controllers[index],
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimations[index],
|
||||
child: SlideTransition(
|
||||
position: _slideAnimations[index],
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimations[index],
|
||||
child: widget.children[index],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 스태거 애니메이션 아이템
|
||||
class StaggeredAnimationItem extends StatefulWidget {
|
||||
final Widget child;
|
||||
final int index;
|
||||
final Duration delay;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
|
||||
const StaggeredAnimationItem({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.index,
|
||||
this.delay = const Duration(milliseconds: 100),
|
||||
this.duration = const Duration(milliseconds: 500),
|
||||
this.curve = Curves.easeOutBack,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StaggeredAnimationItem> createState() => _StaggeredAnimationItemState();
|
||||
}
|
||||
|
||||
class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
// 지연 후 애니메이션 시작
|
||||
Future.delayed(widget.delay * widget.index, () {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 카드 플립 애니메이션
|
||||
class FlipAnimationCard extends StatefulWidget {
|
||||
final Widget front;
|
||||
final Widget back;
|
||||
final Duration duration;
|
||||
|
||||
const FlipAnimationCard({
|
||||
super.key,
|
||||
required this.front,
|
||||
required this.back,
|
||||
this.duration = const Duration(milliseconds: 800),
|
||||
});
|
||||
|
||||
@override
|
||||
State<FlipAnimationCard> createState() => _FlipAnimationCardState();
|
||||
}
|
||||
|
||||
class _FlipAnimationCardState extends State<FlipAnimationCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
bool _isFlipped = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _flip() {
|
||||
if (_isFlipped) {
|
||||
_controller.reverse();
|
||||
} else {
|
||||
_controller.forward();
|
||||
}
|
||||
_isFlipped = !_isFlipped;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _flip,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
final isShowingFront = _animation.value < 0.5;
|
||||
return Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(math.pi * _animation.value),
|
||||
child: isShowingFront
|
||||
? widget.front
|
||||
: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()..rotateY(math.pi),
|
||||
child: widget.back,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import 'website_icon.dart';
|
||||
import 'app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
@@ -230,67 +233,30 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
DetailScreen(subscription: widget.subscription),
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 0.05);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
var tween = Tween(begin: begin, end: end)
|
||||
.chain(CurveTween(curve: curve));
|
||||
|
||||
var fadeAnimation =
|
||||
Tween<double>(begin: 0.6, end: 1.0)
|
||||
.chain(CurveTween(curve: curve))
|
||||
.animate(animation);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신
|
||||
await _subscriptionProvider.refreshSubscriptions();
|
||||
|
||||
// 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림
|
||||
// mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다.
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
// 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용
|
||||
if (mounted) {
|
||||
_subscriptionProvider.notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
splashColor: AppColors.primaryColor.withOpacity(0.1),
|
||||
highlightColor: AppColors.primaryColor.withOpacity(0.05),
|
||||
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
child: AnimatedGlassmorphismCard(
|
||||
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: 16,
|
||||
blur: _isHovering ? 15 : 10,
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? AppColors.primaryColor.withOpacity(0.3)
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.borderColor,
|
||||
width: _isHovering ? 1.5 : 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withOpacity(
|
||||
color: AppColors.primaryColor.withValues(alpha:
|
||||
0.03 + (0.05 * _hoverController.value)),
|
||||
blurRadius: 8 + (8 * _hoverController.value),
|
||||
spreadRadius: 0,
|
||||
@@ -502,9 +468,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
decoration: BoxDecoration(
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
.withOpacity(0.1)
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.successColor
|
||||
.withOpacity(0.1),
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
@@ -551,7 +517,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B).withOpacity(0.1),
|
||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -607,6 +573,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/category_header_widget.dart';
|
||||
import '../widgets/swipeable_subscription_card.dart';
|
||||
import '../widgets/staggered_list_animation.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import '../widgets/animated_page_transitions.dart';
|
||||
import '../widgets/app_navigator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
@@ -75,8 +82,29 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: SubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
child: StaggeredAnimationItem(
|
||||
index: subIndex,
|
||||
delay: const Duration(milliseconds: 50),
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
onTap: () {
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onEdit: () {
|
||||
// 편집 화면으로 이동
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 삭제 확인 다이얼로그
|
||||
final provider = Provider.of<SubscriptionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await provider.deleteSubscription(
|
||||
subscriptions[subIndex].id,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
227
lib/widgets/swipeable_subscription_card.dart
Normal file
227
lib/widgets/swipeable_subscription_card.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'subscription_card.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SwipeableSubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
|
||||
}
|
||||
|
||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
double _dragStartX = 0;
|
||||
double _dragExtent = 0;
|
||||
bool _isSwipingLeft = false;
|
||||
bool _hapticTriggered = false;
|
||||
|
||||
static const double _swipeThreshold = 80.0;
|
||||
static const double _deleteThreshold = 150.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
_dragStartX = details.localPosition.dx;
|
||||
_hapticTriggered = false;
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
final delta = details.localPosition.dx - _dragStartX;
|
||||
setState(() {
|
||||
_dragExtent = delta;
|
||||
_isSwipingLeft = delta < 0;
|
||||
});
|
||||
|
||||
// 햅틱 피드백 트리거
|
||||
if (!_hapticTriggered && _dragExtent.abs() > _swipeThreshold) {
|
||||
_hapticTriggered = true;
|
||||
HapticFeedbackHelper.mediumImpact();
|
||||
}
|
||||
|
||||
// 삭제 임계값에 도달했을 때 강한 햅틱
|
||||
if (_dragExtent.abs() > _deleteThreshold && _hapticTriggered) {
|
||||
HapticFeedbackHelper.heavyImpact();
|
||||
_hapticTriggered = false; // 반복 방지
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||
final extent = _dragExtent.abs();
|
||||
|
||||
if (extent > _deleteThreshold || velocity.abs() > 800) {
|
||||
// 삭제 액션
|
||||
if (_isSwipingLeft && widget.onDelete != null) {
|
||||
HapticFeedbackHelper.success();
|
||||
_animateToOffset(-MediaQuery.of(context).size.width);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
widget.onDelete!();
|
||||
});
|
||||
} else if (!_isSwipingLeft && widget.onEdit != null) {
|
||||
HapticFeedbackHelper.success();
|
||||
_animateToOffset(MediaQuery.of(context).size.width);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
widget.onEdit!();
|
||||
});
|
||||
}
|
||||
} else if (extent > _swipeThreshold) {
|
||||
// 액션 버튼 표시
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
_animateToOffset(_isSwipingLeft ? -_swipeThreshold : _swipeThreshold);
|
||||
} else {
|
||||
// 원위치로 복귀
|
||||
_animateToOffset(0);
|
||||
}
|
||||
}
|
||||
|
||||
void _animateToOffset(double offset) {
|
||||
_animation = Tween<double>(
|
||||
begin: _dragExtent,
|
||||
end: offset,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
_controller.forward(from: 0).then((_) {
|
||||
setState(() {
|
||||
_dragExtent = offset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 배경 액션 버튼들
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _isSwipingLeft
|
||||
? AppColors.dangerColor
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 편집 버튼 (오른쪽 스와이프)
|
||||
if (!_isSwipingLeft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _dragExtent > 40 ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: _dragExtent > 40 ? 1.0 : 0.5,
|
||||
child: const Icon(
|
||||
Icons.edit_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 삭제 버튼 (왼쪽 스와이프)
|
||||
if (_isSwipingLeft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _dragExtent.abs() > 40 ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: _dragExtent.abs() > 40 ? 1.0 : 0.5,
|
||||
child: Icon(
|
||||
_dragExtent.abs() > _deleteThreshold
|
||||
? Icons.delete_forever_rounded
|
||||
: Icons.delete_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 스와이프 가능한 카드
|
||||
AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(_animation.value, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: _handleDragStart,
|
||||
onHorizontalDragUpdate: _handleDragUpdate,
|
||||
onHorizontalDragEnd: _handleDragEnd,
|
||||
child: Transform.translate(
|
||||
offset: Offset(_dragExtent, 0),
|
||||
child: Transform.scale(
|
||||
scale: 1.0 - (_dragExtent.abs() / 2000),
|
||||
child: Transform.rotate(
|
||||
angle: _dragExtent / 2000,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (_dragExtent.abs() < 10) {
|
||||
widget.onTap?.call();
|
||||
}
|
||||
},
|
||||
child: SubscriptionCard(
|
||||
subscription: widget.subscription,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/widgets/themed_text.dart
Normal file
216
lib/widgets/themed_text.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
|
||||
class ThemedText extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool softWrap;
|
||||
final bool forceLight;
|
||||
final bool forceDark;
|
||||
final double? opacity;
|
||||
final double? fontSize;
|
||||
final FontWeight? fontWeight;
|
||||
final double? letterSpacing;
|
||||
final Color? color;
|
||||
|
||||
const ThemedText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap = true,
|
||||
this.forceLight = false,
|
||||
this.forceDark = false,
|
||||
this.opacity,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.letterSpacing,
|
||||
this.color,
|
||||
});
|
||||
|
||||
/// 배경 밝기에 따른 텍스트 색상 결정
|
||||
static Color getContrastColor(BuildContext context, {
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
}) {
|
||||
if (forceLight) return Colors.white;
|
||||
if (forceDark) return AppColors.textPrimary;
|
||||
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
|
||||
if (_isGlassmorphicContext(context)) {
|
||||
return brightness == Brightness.dark
|
||||
? Colors.white.withValues(alpha: 0.95)
|
||||
: AppColors.textPrimary;
|
||||
}
|
||||
|
||||
// 일반 환경
|
||||
return brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: AppColors.textPrimary;
|
||||
}
|
||||
|
||||
/// 글래스모피즘 컨텍스트인지 확인
|
||||
static bool _isGlassmorphicContext(BuildContext context) {
|
||||
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
|
||||
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
|
||||
return glassmorphic != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = color ?? getContrastColor(
|
||||
context,
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
);
|
||||
|
||||
final finalColor = opacity != null
|
||||
? textColor.withValues(alpha: opacity!)
|
||||
: textColor;
|
||||
|
||||
final defaultStyle = DefaultTextStyle.of(context).style;
|
||||
|
||||
// 개별 스타일 속성들을 병합
|
||||
final baseStyle = TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
letterSpacing: letterSpacing,
|
||||
color: finalColor,
|
||||
);
|
||||
|
||||
final effectiveStyle = defaultStyle.merge(baseStyle).merge(style);
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: effectiveStyle,
|
||||
textAlign: textAlign,
|
||||
overflow: overflow,
|
||||
maxLines: maxLines,
|
||||
softWrap: softWrap,
|
||||
);
|
||||
}
|
||||
|
||||
/// 제목용 스타일 팩토리
|
||||
static ThemedText headline({
|
||||
required String text,
|
||||
TextStyle? style,
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
}) {
|
||||
return ThemedText(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
).merge(style),
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
);
|
||||
}
|
||||
|
||||
/// 부제목용 스타일 팩토리
|
||||
static ThemedText subtitle({
|
||||
required String text,
|
||||
TextStyle? style,
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
double opacity = 0.8,
|
||||
}) {
|
||||
return ThemedText(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
).merge(style),
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
opacity: opacity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 본문용 스타일 팩토리
|
||||
static ThemedText body({
|
||||
required String text,
|
||||
TextStyle? style,
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
double opacity = 0.9,
|
||||
}) {
|
||||
return ThemedText(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
).merge(style),
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
opacity: opacity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 캡션용 스타일 팩토리
|
||||
static ThemedText caption({
|
||||
required String text,
|
||||
TextStyle? style,
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
double opacity = 0.7,
|
||||
}) {
|
||||
return ThemedText(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
).merge(style),
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
opacity: opacity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯
|
||||
class GlassmorphicIndicator extends InheritedWidget {
|
||||
const GlassmorphicIndicator({
|
||||
super.key,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static GlassmorphicIndicator? of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<GlassmorphicIndicator>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false;
|
||||
}
|
||||
|
||||
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
|
||||
class GlassmorphicTextWrapper extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const GlassmorphicTextWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassmorphicIndicator(
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: ThemedText.getContrastColor(context),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user