Initial commit: SubManager Flutter App
주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
124
lib/widgets/animated_wave_background.dart
Normal file
124
lib/widgets/animated_wave_background.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||
///
|
||||
/// [controller]와 [pulseController]를 통해 애니메이션을 제어합니다.
|
||||
class AnimatedWaveBackground extends StatelessWidget {
|
||||
final AnimationController controller;
|
||||
final AnimationController pulseController;
|
||||
|
||||
const AnimatedWaveBackground({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.pulseController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||
final angle = controller.value * 2 * math.pi;
|
||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||
final xOffset = 20 * math.sin(angle);
|
||||
final yOffset = 10 * math.cos(angle);
|
||||
|
||||
return Positioned(
|
||||
right: -40 + xOffset,
|
||||
top: -60 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 회전도 선형적으로 변화하도록 수정
|
||||
angle: 0.2 * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||
final xOffset = 20 * math.cos(angle);
|
||||
final yOffset = 10 * math.sin(angle);
|
||||
|
||||
return Positioned(
|
||||
left: -80 + xOffset,
|
||||
bottom: -70 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 반대 방향으로 회전하도록 설정
|
||||
angle: -0.3 * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(110),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 배경에 추가적인 작은 원 추가하여 깊이감 증가
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||
final xOffset = 15 * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * math.cos(angle * 0.7);
|
||||
|
||||
return Positioned(
|
||||
right: 40 + xOffset,
|
||||
bottom: -40 + yOffset,
|
||||
child: Transform.rotate(
|
||||
angle: 0.4 * math.cos(angle * 0.5),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 숨쉬는 효과가 있는 작은 원
|
||||
AnimatedBuilder(
|
||||
animation: pulseController,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: 10,
|
||||
left: 200,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/widgets/category_header_widget.dart
Normal file
58
lib/widgets/category_header_widget.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 카테고리별 구독 그룹의 헤더 위젯
|
||||
///
|
||||
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
|
||||
/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다.
|
||||
class CategoryHeaderWidget extends StatelessWidget {
|
||||
final String categoryName;
|
||||
final int subscriptionCount;
|
||||
final double totalCost;
|
||||
|
||||
const CategoryHeaderWidget({
|
||||
Key? key,
|
||||
required this.categoryName,
|
||||
required this.subscriptionCount,
|
||||
required this.totalCost,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
categoryName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(totalCost)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Color(0xFFEEEEEE),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/widgets/empty_state_widget.dart
Normal file
147
lib/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
/// 애니메이션 효과와 함께 사용자에게 구독 추가를 유도합니다.
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
final AnimationController fadeController;
|
||||
final AnimationController rotateController;
|
||||
final AnimationController slideController;
|
||||
final VoidCallback onAddPressed;
|
||||
|
||||
const EmptyStateWidget({
|
||||
Key? key,
|
||||
required this.fadeController,
|
||||
required this.rotateController,
|
||||
required this.slideController,
|
||||
required this.onAddPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Center(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: Container(
|
||||
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: [
|
||||
AnimatedBuilder(
|
||||
animation: rotateController,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: rotateController.value * 2 * math.pi,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF3B82F6), Color(0xFF2563EB)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 48,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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 SizedBox(height: 8),
|
||||
const Text(
|
||||
'새로운 구독을 추가해보세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
MouseRegion(
|
||||
onEnter: (_) => {},
|
||||
onExit: (_) => {},
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 4,
|
||||
backgroundColor: const Color(0xFF3B82F6),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
onAddPressed();
|
||||
},
|
||||
child: const Text(
|
||||
'구독 추가하기',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/widgets/exchange_rate_widget.dart
Normal file
153
lib/widgets/exchange_rate_widget.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
|
||||
/// 환율 정보를 표시하는 위젯
|
||||
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
|
||||
class ExchangeRateWidget extends StatefulWidget {
|
||||
/// 달러 금액 변화 감지용 TextEditingController
|
||||
final TextEditingController costController;
|
||||
|
||||
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
|
||||
final bool showExchangeRate;
|
||||
|
||||
const ExchangeRateWidget({
|
||||
Key? key,
|
||||
required this.costController,
|
||||
required this.showExchangeRate,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
|
||||
}
|
||||
|
||||
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
|
||||
final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
String _exchangeRateInfo = '';
|
||||
String _convertedAmount = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExchangeRate();
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.costController.removeListener(_updateConvertedAmount);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExchangeRateWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
|
||||
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
|
||||
oldWidget.costController.removeListener(_updateConvertedAmount);
|
||||
|
||||
if (widget.showExchangeRate) {
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
_loadExchangeRate();
|
||||
_updateConvertedAmount();
|
||||
} else {
|
||||
setState(() {
|
||||
_exchangeRateInfo = '';
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 로드
|
||||
Future<void> _loadExchangeRate() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exchangeRateInfo = rateInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 달러 금액이 변경될 때 원화 금액 업데이트
|
||||
Future<void> _updateConvertedAmount() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
try {
|
||||
// 금액 입력값에서 콤마 제거 후 숫자로 변환
|
||||
final text = widget.costController.text.replaceAll(',', '');
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(text);
|
||||
if (amount != null) {
|
||||
final converted =
|
||||
await _exchangeRateService.getFormattedKrwAmount(amount);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_convertedAmount = converted;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 빈 문자열 표시
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 위젯 생성
|
||||
Widget buildExchangeRateInfo() {
|
||||
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_exchangeRateInfo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 환산 금액 텍스트 위젯 생성
|
||||
Widget buildConvertedAmount() {
|
||||
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_convertedAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.showExchangeRate) {
|
||||
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
|
||||
String get exchangeRateInfo => _exchangeRateInfo;
|
||||
|
||||
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
|
||||
String get convertedAmount => _convertedAmount;
|
||||
}
|
||||
280
lib/widgets/main_summary_card.dart
Normal file
280
lib/widgets/main_summary_card.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/format_helper.dart';
|
||||
import 'animated_wave_background.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,
|
||||
required this.fadeController,
|
||||
required this.pulseController,
|
||||
required this.waveController,
|
||||
required this.slideController,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double monthlyCost = provider.totalMonthlyExpense;
|
||||
final double yearlyCost = monthlyCost * 12;
|
||||
final int totalSubscriptions = provider.subscriptions.length;
|
||||
final double eventSavings = provider.totalEventSavings;
|
||||
final int activeEvents = provider.activeEventSubscriptions.length;
|
||||
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
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: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
),
|
||||
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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '연간 구독 비용',
|
||||
value: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(yearlyCost)}원',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '총 구독 서비스',
|
||||
value: '$totalSubscriptions개',
|
||||
),
|
||||
],
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.2),
|
||||
Colors.white.withOpacity(0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBox(BuildContext context,
|
||||
{required String title, required String value}) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
199
lib/widgets/native_ad_widget.dart
Normal file
199
lib/widgets/native_ad_widget.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
|
||||
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||
class NativeAdWidget extends StatefulWidget {
|
||||
const NativeAdWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NativeAdWidget> createState() => _NativeAdWidgetState();
|
||||
}
|
||||
|
||||
class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
NativeAd? _nativeAd;
|
||||
bool _isLoaded = false;
|
||||
String? _error;
|
||||
bool _isAdLoading = false; // 광고 로드 중복 방지 플래그
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// initState에서는 Theme.of(context)와 같은 InheritedWidget에 의존하지 않음
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 위젯이 완전히 초기화된 후 광고 로드
|
||||
if (!_isAdLoading && !kIsWeb) {
|
||||
_loadAd();
|
||||
_isAdLoading = true; // 중복 로드 방지
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이티브 광고 로드 함수
|
||||
void _loadAd() {
|
||||
// 웹 또는 Android/iOS가 아닌 경우 광고 로드 방지
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_nativeAd = NativeAd(
|
||||
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요
|
||||
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
|
||||
request: const AdRequest(),
|
||||
listener: NativeAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
setState(() {
|
||||
_isLoaded = true;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (ad, error) {
|
||||
ad.dispose();
|
||||
setState(() {
|
||||
_error = error.message;
|
||||
});
|
||||
},
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
/// 테스트 광고 단위 ID 반환 함수
|
||||
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
|
||||
String _testAdUnitId() {
|
||||
if (Platform.isAndroid) {
|
||||
// Android 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/2247696110';
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/3986624511';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nativeAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 웹용 광고 플레이스홀더 위젯
|
||||
Widget _buildWebPlaceholder() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.ad_units,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'광고영역',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 웹 환경인 경우 플레이스홀더 표시
|
||||
if (kIsWeb) {
|
||||
return _buildWebPlaceholder();
|
||||
}
|
||||
|
||||
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
// 광고 로드 실패 시 빈 공간 반환
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_isLoaded) {
|
||||
// 광고 로딩 중 로딩 인디케이터 표시
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: SizedBox(
|
||||
height: 80, // 네이티브 광고 높이 조정
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/widgets/skeleton_loading.dart
Normal file
93
lib/widgets/skeleton_loading.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
const SkeletonLoading({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSkeletonColumn(),
|
||||
_buildSkeletonColumn(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Card(
|
||||
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],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
618
lib/widgets/subscription_card.dart
Normal file
618
lib/widgets/subscription_card.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'package:flutter/material.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 '../theme/app_colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
|
||||
const SubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubscriptionCard> createState() => _SubscriptionCardState();
|
||||
}
|
||||
|
||||
class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _hoverController;
|
||||
bool _isHovering = false;
|
||||
final double _initialElevation = 1.0;
|
||||
final double _hoveredElevation = 3.0;
|
||||
late SubscriptionProvider _subscriptionProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hoverController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_subscriptionProvider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoverController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHover(bool isHovering) {
|
||||
setState(() {
|
||||
_isHovering = isHovering;
|
||||
if (isHovering) {
|
||||
_hoverController.forward();
|
||||
} else {
|
||||
_hoverController.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 다음 결제 예정일 정보를 생성
|
||||
String _getNextBillingText() {
|
||||
final now = DateTime.now();
|
||||
final nextBillingDate = widget.subscription.nextBillingDate;
|
||||
|
||||
// 날짜 비교를 위해 시간 제거 (날짜만 비교)
|
||||
final dateOnlyNow = DateTime(now.year, now.month, now.day);
|
||||
final dateOnlyBilling = DateTime(
|
||||
nextBillingDate.year, nextBillingDate.month, nextBillingDate.day);
|
||||
|
||||
// 오늘이 결제일인 경우
|
||||
if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) {
|
||||
return '오늘 결제 예정';
|
||||
}
|
||||
|
||||
// 미래 날짜인 경우 남은 일수 계산
|
||||
if (dateOnlyBilling.isAfter(dateOnlyNow)) {
|
||||
final difference = dateOnlyBilling.difference(dateOnlyNow).inDays;
|
||||
return '$difference일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 과거 날짜인 경우, 다음 결제일 계산
|
||||
final billingCycle = widget.subscription.billingCycle;
|
||||
|
||||
// 월간 구독인 경우
|
||||
if (billingCycle == '월간') {
|
||||
// 결제일에 해당하는 날짜 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int nextMonth = now.month;
|
||||
int nextYear = now.year;
|
||||
|
||||
// 해당 월의 마지막 날짜 확인 (예: 31일이 없는 달)
|
||||
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
|
||||
if (day > lastDayOfMonth) {
|
||||
day = lastDayOfMonth;
|
||||
}
|
||||
|
||||
// 결제일이 이번 달에서 이미 지났으면 다음 달로 설정
|
||||
if (now.day > day) {
|
||||
nextMonth++;
|
||||
if (nextMonth > 12) {
|
||||
nextMonth = 1;
|
||||
nextYear++;
|
||||
}
|
||||
|
||||
// 다음 달의 마지막 날짜 확인
|
||||
final lastDayOfNextMonth = DateTime(nextYear, nextMonth + 1, 0).day;
|
||||
if (day > lastDayOfNextMonth) {
|
||||
day = lastDayOfNextMonth;
|
||||
}
|
||||
}
|
||||
|
||||
final nextDate = DateTime(nextYear, nextMonth, day);
|
||||
final days = nextDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 연간 구독인 경우
|
||||
if (billingCycle == '연간') {
|
||||
// 결제일에 해당하는 날짜와 월 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int month = nextBillingDate.month;
|
||||
int year = now.year;
|
||||
|
||||
// 해당 월의 마지막 날짜 확인
|
||||
final lastDayOfMonth = DateTime(year, month + 1, 0).day;
|
||||
if (day > lastDayOfMonth) {
|
||||
day = lastDayOfMonth;
|
||||
}
|
||||
|
||||
// 올해의 결제일
|
||||
final thisYearDate = DateTime(year, month, day);
|
||||
|
||||
// 올해 결제일이 이미 지났으면 내년으로 계산
|
||||
if (thisYearDate.isBefore(dateOnlyNow) ||
|
||||
thisYearDate.isAtSameMomentAs(dateOnlyNow)) {
|
||||
year++;
|
||||
|
||||
// 내년 해당 월의 마지막 날짜 확인
|
||||
final lastDayOfNextYear = DateTime(year, month + 1, 0).day;
|
||||
if (day > lastDayOfNextYear) {
|
||||
day = lastDayOfNextYear;
|
||||
}
|
||||
|
||||
final nextYearDate = DateTime(year, month, day);
|
||||
final days = nextYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
} else {
|
||||
final days = thisYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
}
|
||||
}
|
||||
|
||||
// 주간 구독인 경우
|
||||
if (billingCycle == '주간') {
|
||||
// 결제 요일 가져오기
|
||||
final billingWeekday = nextBillingDate.weekday;
|
||||
// 현재 요일
|
||||
final currentWeekday = now.weekday;
|
||||
|
||||
// 다음 같은 요일까지 남은 일수 계산
|
||||
int daysUntilNext;
|
||||
if (currentWeekday < billingWeekday) {
|
||||
daysUntilNext = billingWeekday - currentWeekday;
|
||||
} else if (currentWeekday > billingWeekday) {
|
||||
daysUntilNext = 7 - (currentWeekday - billingWeekday);
|
||||
} else {
|
||||
// 같은 요일
|
||||
daysUntilNext = 7; // 다음 주 같은 요일
|
||||
}
|
||||
|
||||
if (daysUntilNext == 0) return '오늘 결제 예정';
|
||||
return '$daysUntilNext일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 기본값 - 예상할 수 없는 경우
|
||||
return '결제일 정보 필요';
|
||||
}
|
||||
|
||||
// 결제일이 가까운지 확인 (7일 이내)
|
||||
bool _isNearBilling() {
|
||||
final text = _getNextBillingText();
|
||||
if (text == '오늘 결제 예정') return true;
|
||||
|
||||
final regex = RegExp(r'(\d+)일 후');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final days = int.parse(match.group(1) ?? '0');
|
||||
return days <= 7;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Color _getCardColor() {
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isNearBilling = _isNearBilling();
|
||||
final Color cardColor = _getCardColor();
|
||||
|
||||
return Hero(
|
||||
tag: 'subscription_${widget.subscription.id}',
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onHover(true),
|
||||
onExit: (_) => _onHover(false),
|
||||
child: AnimatedBuilder(
|
||||
animation: _hoverController,
|
||||
builder: (context, child) {
|
||||
final elevation = _initialElevation +
|
||||
(_hoveredElevation - _initialElevation) *
|
||||
_hoverController.value;
|
||||
|
||||
final scale = 1.0 + (0.02 * _hoverController.value);
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Material(
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
splashColor: AppColors.primaryColor.withOpacity(0.1),
|
||||
highlightColor: AppColors.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? AppColors.primaryColor.withOpacity(0.3)
|
||||
: AppColors.borderColor,
|
||||
width: _isHovering ? 1.5 : 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withOpacity(
|
||||
0.03 + (0.05 * _hoverController.value)),
|
||||
blurRadius: 8 + (8 * _hoverController.value),
|
||||
spreadRadius: 0,
|
||||
offset: Offset(0, 4 + (2 * _hoverController.value)),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 그라데이션 상단 바 효과
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: widget.subscription.isCurrentlyInEvent
|
||||
? [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
]
|
||||
: isNearBilling
|
||||
? AppColors.amberGradient
|
||||
: AppColors.blueGradient,
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 서비스 아이콘
|
||||
WebsiteIcon(
|
||||
key: ValueKey(
|
||||
'subscription_icon_${widget.subscription.id}'),
|
||||
url: widget.subscription.websiteUrl,
|
||||
serviceName: widget.subscription.serviceName,
|
||||
size: 48,
|
||||
isHovered: _isHovering,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 서비스 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 서비스명
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 배지들
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 이벤트 배지
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 11,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'이벤트',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
|
||||
// 결제 주기 배지
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.subscription.billingCycle,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// 가격 정보
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 가격 표시 (이벤트 가격 반영)
|
||||
Row(
|
||||
children: [
|
||||
// 이벤트 중인 경우 원래 가격을 취소선으로 표시
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.monthlyCost)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.monthlyCost),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 현재 가격 (이벤트 또는 정상 가격)
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.currentPrice)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.currentPrice),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription.isCurrentlyInEvent
|
||||
? const Color(0xFFFF6B6B)
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 결제 예정일 정보
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
.withOpacity(0.1)
|
||||
: AppColors.successColor
|
||||
.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isNearBilling
|
||||
? Icons
|
||||
.access_time_filled_rounded
|
||||
: Icons
|
||||
.check_circle_rounded,
|
||||
size: 12,
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
: AppColors.successColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_getNextBillingText(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
: AppColors.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 이벤트 절약액 표시
|
||||
if (widget.subscription.isCurrentlyInEvent &&
|
||||
widget.subscription.eventSavings > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.savings_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? '${NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget.subscription.eventSavings)} 절약'
|
||||
: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget.subscription.eventSavings)} 절약',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 이벤트 종료일까지 남은 일수
|
||||
if (widget.subscription.eventEndDate != null) ...[
|
||||
Text(
|
||||
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/widgets/subscription_list_widget.dart
Normal file
117
lib/widgets/subscription_list_widget.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/category_header_widget.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
|
||||
final AnimationController fadeController;
|
||||
|
||||
const SubscriptionListWidget({
|
||||
Key? key,
|
||||
required this.categorizedSubscriptions,
|
||||
required this.fadeController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 카테고리 키 목록 (정렬된)
|
||||
final categories = categorizedSubscriptions.keys.toList();
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final category = categories[index];
|
||||
final subscriptions = categorizedSubscriptions[category]!;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 카테고리 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: CategoryHeaderWidget(
|
||||
categoryName: category,
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCost: subscriptions.fold(
|
||||
0.0,
|
||||
(sum, sub) => sum + sub.monthlyCost,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 카테고리별 구독 목록
|
||||
FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: fadeController, curve: Curves.easeIn)),
|
||||
child: ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: subscriptions.length,
|
||||
itemBuilder: (context, subIndex) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
final delay = 0.05 * subIndex;
|
||||
final animationBegin = 0.2;
|
||||
final animationEnd = 1.0;
|
||||
final intervalStart = delay;
|
||||
final intervalEnd = intervalStart + 0.4;
|
||||
|
||||
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
||||
final intervalStartNormalized =
|
||||
intervalStart.clamp(0.0, 0.9);
|
||||
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(
|
||||
begin: animationBegin, end: animationEnd)
|
||||
.animate(CurvedAnimation(
|
||||
parent: fadeController,
|
||||
curve: Interval(intervalStartNormalized,
|
||||
intervalEndNormalized,
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: SubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: categories.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
|
||||
class MultiSliver extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const MultiSliver({
|
||||
Key? key,
|
||||
required this.children,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= children.length) return null;
|
||||
return children[index];
|
||||
},
|
||||
childCount: children.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
748
lib/widgets/website_icon.dart
Normal file
748
lib/widgets/website_icon.dart
Normal file
@@ -0,0 +1,748 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:html/dom.dart' as html_dom;
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
// 파비콘 캐시 관리 클래스
|
||||
class FaviconCache {
|
||||
// 메모리 캐시 (앱 전체에서 공유)
|
||||
static final Map<String, String> _memoryCache = {};
|
||||
|
||||
// 로딩 상태 추적 (동시에 동일한 URL에 대한 중복 요청 방지)
|
||||
static final Set<String> _loadingKeys = {};
|
||||
|
||||
// 메모리 캐시에서 파비콘 URL 가져오기
|
||||
static String? getFromMemory(String serviceKey) {
|
||||
return _memoryCache[serviceKey];
|
||||
}
|
||||
|
||||
// 메모리 캐시에 파비콘 URL 저장
|
||||
static void saveToMemory(String serviceKey, String logoUrl) {
|
||||
_memoryCache[serviceKey] = logoUrl;
|
||||
// 로딩 완료 표시
|
||||
_loadingKeys.remove(serviceKey);
|
||||
}
|
||||
|
||||
// 로딩 시작 표시 (중복 요청 방지용)
|
||||
static bool markAsLoading(String serviceKey) {
|
||||
if (_loadingKeys.contains(serviceKey)) {
|
||||
// 이미 로딩 중이면 false 반환
|
||||
return false;
|
||||
}
|
||||
_loadingKeys.add(serviceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 로딩 취소 표시
|
||||
static void cancelLoading(String serviceKey) {
|
||||
_loadingKeys.remove(serviceKey);
|
||||
}
|
||||
|
||||
// SharedPreferences에서 파비콘 URL 로드
|
||||
static Future<String?> getFromPrefs(String serviceKey) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 로드 오류: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// SharedPreferences에 파비콘 URL 저장
|
||||
static Future<void> saveToPrefs(String serviceKey, String logoUrl) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('favicon_$serviceKey', logoUrl);
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 저장 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 파비콘 캐시 삭제
|
||||
static Future<void> remove(String serviceKey) async {
|
||||
_memoryCache.remove(serviceKey);
|
||||
_loadingKeys.remove(serviceKey);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 삭제 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 앱에서 로컬 파비콘 파일 경로 가져오기
|
||||
static Future<String?> getLocalFaviconPath(String serviceKey) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('local_favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 로드 오류: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 앱에서 로컬 파비콘 파일 경로 저장
|
||||
static Future<void> saveLocalFaviconPath(
|
||||
String serviceKey, String filePath) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('local_favicon_$serviceKey', filePath);
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 저장 오류: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구글 파비콘 API 서비스
|
||||
class GoogleFaviconService {
|
||||
// CORS 프록시 서버 목록
|
||||
static final List<String> _corsProxies = [
|
||||
'https://corsproxy.io/?',
|
||||
'https://api.allorigins.win/raw?url=',
|
||||
'https://cors-anywhere.herokuapp.com/',
|
||||
];
|
||||
|
||||
// 현재 사용 중인 프록시 인덱스
|
||||
static int _currentProxyIndex = 0;
|
||||
|
||||
// 프록시를 사용하여 URL 생성
|
||||
static String _getProxiedUrl(String url) {
|
||||
// 앱 환경에서는 프록시 없이 직접 URL 반환
|
||||
if (!kIsWeb) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
final proxy = _corsProxies[_currentProxyIndex];
|
||||
_currentProxyIndex =
|
||||
(_currentProxyIndex + 1) % _corsProxies.length; // 다음 요청은 다른 프록시 사용
|
||||
|
||||
// URL 인코딩
|
||||
final encodedUrl = Uri.encodeComponent(url);
|
||||
return '$proxy$encodedUrl';
|
||||
}
|
||||
|
||||
// 구글 파비콘 API URL 생성
|
||||
static String getFaviconUrl(String domain, int size) {
|
||||
final directUrl =
|
||||
'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://$domain&size=$size';
|
||||
return directUrl;
|
||||
}
|
||||
|
||||
// DuckDuckGo 파비콘 API URL 생성 (CORS 친화적)
|
||||
static String getDuckDuckGoFaviconUrl(String domain) {
|
||||
return 'https://icons.duckduckgo.com/ip3/$domain.ico';
|
||||
}
|
||||
|
||||
// 웹 환경용 이미지 URL 생성 (다양한 파비콘 서비스 시도)
|
||||
static String getWebFaviconUrl(String domain, int size) {
|
||||
// 다양한 파비콘 서비스 URL 목록
|
||||
final List<String> faviconServices = [
|
||||
// DuckDuckGo의 파비콘 서비스 (CORS 친화적)
|
||||
'https://icons.duckduckgo.com/ip3/$domain.ico',
|
||||
|
||||
// Google의 S2 파비콘 서비스
|
||||
'https://www.google.com/s2/favicons?domain=$domain&sz=$size',
|
||||
];
|
||||
|
||||
// 첫 번째 서비스 사용 (DuckDuckGo 파비콘)
|
||||
return faviconServices[0];
|
||||
}
|
||||
|
||||
// Base64로 인코딩된 기본 파비콘 (웹 환경 CORS 문제 완전 우회용)
|
||||
static String getBase64PlaceholderIcon(String serviceName, Color color) {
|
||||
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
|
||||
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
|
||||
final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2);
|
||||
|
||||
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
|
||||
final svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
|
||||
'<rect width="64" height="64" rx="12" fill="#$colorHex"/>'
|
||||
'<text x="32" y="40" font-family="Arial, sans-serif" font-size="32" font-weight="bold" text-anchor="middle" fill="white">$initial</text>'
|
||||
'</svg>';
|
||||
|
||||
// SVG를 Base64로 인코딩
|
||||
final base64 = base64Encode(utf8.encode(svgContent));
|
||||
return 'data:image/svg+xml;base64,$base64';
|
||||
}
|
||||
}
|
||||
|
||||
class WebsiteIcon extends StatefulWidget {
|
||||
final String? url;
|
||||
final String serviceName;
|
||||
final double size;
|
||||
final bool isHovered;
|
||||
|
||||
const WebsiteIcon({
|
||||
super.key,
|
||||
this.url,
|
||||
required this.serviceName,
|
||||
required this.size,
|
||||
this.isHovered = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebsiteIcon> createState() => _WebsiteIconState();
|
||||
}
|
||||
|
||||
class _WebsiteIconState extends State<WebsiteIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String? _logoUrl; // 웹에서 사용할 로고 URL
|
||||
String? _localLogoPath; // 앱에서 사용할 로컬 파일 경로
|
||||
bool _isLoading = true;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
// 각 인스턴스에 대한 고유 식별자 추가
|
||||
final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
// 서비스와 URL 조합으로 캐시 키 생성
|
||||
String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}';
|
||||
// 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용)
|
||||
String? _previousServiceKey;
|
||||
// 로드 시작된 시점
|
||||
DateTime? _loadStartTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.easeOutCubic));
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut));
|
||||
|
||||
// 초기 _previousServiceKey 설정
|
||||
_previousServiceKey = _serviceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 최초 로딩
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
|
||||
// 캐시를 활용해 파비콘 로드
|
||||
Future<void> _loadFaviconWithCache() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (kIsWeb) {
|
||||
// 웹 환경: 메모리 캐시 확인 후 Google API 사용
|
||||
String? cachedLogo = FaviconCache.getFromMemory(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
cachedLogo = FaviconCache.getFromMemory(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForWeb();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SharedPreferences 확인
|
||||
cachedLogo = await FaviconCache.getFromPrefs(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
FaviconCache.saveToMemory(_serviceKey, cachedLogo);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 웹용 로고 가져오기
|
||||
_fetchLogoForWeb();
|
||||
} else {
|
||||
// 앱 환경: 로컬 파일 확인 후 다운로드
|
||||
|
||||
// 1. 로컬 파일 경로 확인
|
||||
String? localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localLogoPath = localPath;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
setState(() {
|
||||
_localLogoPath = localPath;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 앱용 로고 다운로드
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(WebsiteIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 서비스명이나 URL이 변경된 경우에만 다시 로드
|
||||
final currentServiceKey = _serviceKey;
|
||||
if (_previousServiceKey != currentServiceKey) {
|
||||
print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey');
|
||||
_previousServiceKey = currentServiceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 변경된 서비스 정보로 파비콘 로드
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
|
||||
// 호버 상태 변경 처리
|
||||
if (widget.isHovered != oldWidget.isHovered) {
|
||||
if (widget.isHovered) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 서비스 이름에서 초기 문자 추출
|
||||
String _getInitials() {
|
||||
if (widget.serviceName.isEmpty) return '?';
|
||||
|
||||
final words = widget.serviceName.split(' ');
|
||||
if (words.length == 1) {
|
||||
return words[0][0].toUpperCase();
|
||||
} else if (words.length > 1) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
} else {
|
||||
return widget.serviceName[0].toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 이름을 기반으로 색상 선택
|
||||
Color _getColorFromName() {
|
||||
final int hash = widget.serviceName.hashCode.abs();
|
||||
final List<Color> colors = [
|
||||
AppColors.primaryColor,
|
||||
AppColors.successColor,
|
||||
AppColors.infoColor,
|
||||
AppColors.warningColor,
|
||||
AppColors.dangerColor,
|
||||
];
|
||||
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
// 도메인 추출 메서드
|
||||
String? _extractDomain() {
|
||||
if (widget.url == null || widget.url!.isEmpty) return null;
|
||||
|
||||
// URL 형식 처리 개선
|
||||
String processedUrl = widget.url!;
|
||||
|
||||
// URL에 http:// 또는 https:// 접두사가 없는 경우 추가
|
||||
if (!processedUrl.startsWith('http://') &&
|
||||
!processedUrl.startsWith('https://')) {
|
||||
processedUrl = 'https://$processedUrl';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(processedUrl);
|
||||
if (uri.host.isEmpty) return processedUrl; // 파싱 실패 시 원본 URL 반환
|
||||
return uri.host;
|
||||
} catch (e) {
|
||||
// URL 파싱 실패 시 도메인만 있는 경우를 처리
|
||||
if (processedUrl.contains('.')) {
|
||||
// 간단한 도메인 형식 검사 (예: netflix.com)
|
||||
final domainPattern = RegExp(
|
||||
r'^(https?:\/\/)?(www\.)?([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)',
|
||||
);
|
||||
final match = domainPattern.firstMatch(processedUrl);
|
||||
if (match != null && match.group(3) != null) {
|
||||
return match.group(3);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 웹 환경용 파비콘 가져오기
|
||||
Future<void> _fetchLogoForWeb() async {
|
||||
try {
|
||||
final domain = _extractDomain();
|
||||
if (domain == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 시작 시간 기록
|
||||
final loadStartTime = DateTime.now();
|
||||
|
||||
// 1. DuckDuckGo Favicon API 시도 (CORS 친화적)
|
||||
final ddgFaviconUrl =
|
||||
GoogleFaviconService.getDuckDuckGoFaviconUrl(domain);
|
||||
|
||||
try {
|
||||
// 이미지 존재 여부 확인을 위한 HEAD 요청
|
||||
final response = await http.head(Uri.parse(ddgFaviconUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// DuckDuckGo로부터 파비콘을 성공적으로 가져옴
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = ddgFaviconUrl;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
FaviconCache.saveToMemory(_serviceKey, ddgFaviconUrl);
|
||||
FaviconCache.saveToPrefs(_serviceKey, ddgFaviconUrl);
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
print('DuckDuckGo 파비콘 API 요청 실패: $e');
|
||||
// 실패 시 백업 방법으로 진행
|
||||
}
|
||||
|
||||
// 2. DuckDuckGo API가 실패하면 Base64 인코딩된 SVG 이미지를 로고로 사용 (CORS 회피)
|
||||
final color = _getColorFromName();
|
||||
final base64Logo = GoogleFaviconService.getBase64PlaceholderIcon(
|
||||
widget.serviceName, color);
|
||||
|
||||
// 최소 로딩 시간 보장 (깜박임 방지)
|
||||
final processingTime =
|
||||
DateTime.now().difference(loadStartTime).inMilliseconds;
|
||||
if (processingTime < 300) {
|
||||
await Future.delayed(Duration(milliseconds: 300 - processingTime));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = base64Logo;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 캐시에 저장
|
||||
FaviconCache.saveToMemory(_serviceKey, base64Logo);
|
||||
FaviconCache.saveToPrefs(_serviceKey, base64Logo);
|
||||
}
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('웹용 파비콘 가져오기 오류: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 앱 환경용 파비콘 다운로드 및 로컬 저장
|
||||
Future<void> _fetchLogoForApp() async {
|
||||
try {
|
||||
final domain = _extractDomain();
|
||||
if (domain == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 시작 시간 기록
|
||||
final loadStartTime = DateTime.now();
|
||||
|
||||
// 1. Google API를 통해 파비콘 URL 생성
|
||||
final faviconUrl =
|
||||
GoogleFaviconService.getFaviconUrl(domain, widget.size.toInt() * 2);
|
||||
|
||||
// 2. http.get()으로 이미지 다운로드
|
||||
final response = await http.get(Uri.parse(faviconUrl));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('파비콘 다운로드 실패: ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 3. Uint8List로 변환
|
||||
final Uint8List imageBytes = response.bodyBytes;
|
||||
|
||||
// 4. 고유한 파일명 생성 (서비스명 + URL의 해시)
|
||||
final String hash =
|
||||
md5.convert(utf8.encode(_serviceKey)).toString().substring(0, 8);
|
||||
final String fileName =
|
||||
'favicon_${widget.serviceName.replaceAll(' ', '_')}_$hash.png';
|
||||
|
||||
// 5. 임시 디렉토리 가져오기
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final faviconDir = Directory('${appDir.path}/favicons');
|
||||
if (!await faviconDir.exists()) {
|
||||
await faviconDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// 6. PNG 파일로 저장
|
||||
final File file = File('${faviconDir.path}/$fileName');
|
||||
await file.writeAsBytes(imageBytes);
|
||||
final String localFilePath = file.path;
|
||||
|
||||
// 최소 로딩 시간 보장 (깜박임 방지)
|
||||
final processingTime =
|
||||
DateTime.now().difference(loadStartTime).inMilliseconds;
|
||||
if (processingTime < 300) {
|
||||
await Future.delayed(Duration(milliseconds: 300 - processingTime));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localLogoPath = localFilePath;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 로컬 파일 경로 캐시에 저장
|
||||
FaviconCache.saveLocalFaviconPath(_serviceKey, localFilePath);
|
||||
}
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('앱용 파비콘 다운로드 오류: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
key: ValueKey(
|
||||
'icon_container_${widget.serviceName}_${widget.url ?? ''}_$_uniqueId'),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
boxShadow: widget.isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getColorFromName().withAlpha(76), // 약 0.3 알파값
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: _buildIconContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconContent() {
|
||||
// 로딩 중 표시
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: widget.size * 0.4,
|
||||
height: widget.size * 0.4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
// 웹 환경: 파비콘 API에서 가져온 이미지 또는 Base64 인코딩된 이미지 사용
|
||||
if (_logoUrl != null) {
|
||||
if (_logoUrl!.startsWith('data:image/svg+xml;base64')) {
|
||||
// Base64 인코딩된 SVG 이미지인 경우
|
||||
return ClipRRect(
|
||||
key: ValueKey('web_svg_logo_${_logoUrl!.hashCode}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: SvgPicture.string(
|
||||
utf8.decode(base64.decode(_logoUrl!.split(',')[1])),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// DuckDuckGo나 다른 파비콘 서비스에서 가져온 URL인 경우
|
||||
return ClipRRect(
|
||||
key: ValueKey('web_url_logo_${_logoUrl!.hashCode}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _logoUrl!,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: widget.size * 0.4,
|
||||
height: widget.size * 0.4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return _buildFallbackIcon();
|
||||
} else {
|
||||
// 앱 환경: 로컬 파일 표시
|
||||
if (_localLogoPath == null) {
|
||||
return _buildFallbackIcon();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
key: ValueKey('local_logo_${_localLogoPath}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: Image.file(
|
||||
File(_localLogoPath!),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildFallbackIcon();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFallbackIcon() {
|
||||
final color = _getColorFromName();
|
||||
|
||||
return Container(
|
||||
key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
color,
|
||||
color.withAlpha(204), // 약 0.8 알파값
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getInitials(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: widget.size * 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user