회원목록

This commit is contained in:
2025-08-01 16:45:26 +09:00
parent a996def67a
commit ed8c7eacba
191 changed files with 17491 additions and 0 deletions
@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
class SubscriptionDetailsScreen extends StatefulWidget {
final SubscriptionPlan plan;
final bool isYearly;
const SubscriptionDetailsScreen({
super.key,
required this.plan,
required this.isYearly,
});
@override
State<SubscriptionDetailsScreen> createState() => _SubscriptionDetailsScreenState();
}
class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final price = widget.isYearly ? widget.plan.yearlyPrice : widget.plan.monthlyPrice;
final period = widget.isYearly ? '' : '';
return Scaffold(
appBar: AppBar(
title: Text('${widget.plan.name} 플랜'),
),
body: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 플랜 헤더
_buildPlanHeader(price, period),
const SizedBox(height: 24),
// 플랜 설명
Text(
widget.plan.description,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
// 플랜 혜택
const Text(
'플랜 혜택',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...widget.plan.features.map((feature) => _buildFeatureItem(feature)),
const SizedBox(height: 24),
// 플랜 세부 정보
const Text(
'세부 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem('최대 회원 수', '${widget.plan.maxMembers}'),
_buildDetailItem('최대 이벤트 수', '${widget.plan.maxEvents}'),
_buildDetailItem(
'고급 통계 기능',
widget.plan.hasAdvancedStats ? '제공' : '미제공',
isAvailable: widget.plan.hasAdvancedStats,
),
_buildDetailItem(
'맞춤형 기능',
widget.plan.hasCustomization ? '제공' : '미제공',
isAvailable: widget.plan.hasCustomization,
),
_buildDetailItem(
'우선 지원',
widget.plan.hasPriority ? '제공' : '미제공',
isAvailable: widget.plan.hasPriority,
),
const SizedBox(height: 24),
// 결제 정보
const Text(
'결제 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem(
'결제 주기',
widget.isYearly ? '연간' : '월간',
),
_buildDetailItem(
'결제 금액',
'${_formatPrice(price)}/${widget.isYearly ? '' : ''}',
),
if (widget.isYearly)
_buildDetailItem(
'월 환산 금액',
'${_formatPrice(widget.plan.yearlyPrice / 12)}/월',
),
_buildDetailItem(
'자동 갱신',
'활성화 (설정에서 변경 가능)',
),
// 하단 여백 (버튼 높이만큼)
const SizedBox(height: 80),
],
),
),
// 하단 구독 버튼
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: ElevatedButton(
onPressed: _isLoading ? null : () => _subscribe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
widget.plan.type == SubscriptionPlanType.free
? '무료 플랜 시작하기'
: '구독하기',
),
),
),
),
],
),
);
}
Widget _buildPlanHeader(double price, String period) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: _getPlanColor(),
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Icon(
_getPlanIcon(),
color: _getPlanColor(),
size: 32,
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.plan.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (widget.plan.type == SubscriptionPlanType.premium)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기 플랜',
style: TextStyle(
color: Colors.amber,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'/$period',
style: const TextStyle(fontSize: 16),
),
),
],
),
if (widget.isYearly)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'월 ₩${_formatPrice(widget.plan.yearlyPrice / 12)} (20% 할인)',
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildFeatureItem(String feature) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(feature, style: const TextStyle(fontSize: 16))),
],
),
);
}
Widget _buildDetailItem(String label, String value, {bool isAvailable = true}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
fontSize: 16,
color: isAvailable ? null : Colors.grey,
),
),
),
],
),
);
}
Color _getPlanColor() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Colors.grey;
case SubscriptionPlanType.basic:
return Colors.blue;
case SubscriptionPlanType.premium:
return Colors.amber;
case SubscriptionPlanType.enterprise:
return Colors.purple;
}
}
IconData _getPlanIcon() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Icons.star_border;
case SubscriptionPlanType.basic:
return Icons.star_half;
case SubscriptionPlanType.premium:
return Icons.star;
case SubscriptionPlanType.enterprise:
return Icons.workspace_premium;
}
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
Future<void> _subscribe(BuildContext context) async {
setState(() {
_isLoading = true;
});
try {
final subscriptionService = Provider.of<SubscriptionService>(context, listen: false);
final success = await subscriptionService.createSubscription(
widget.plan.type,
widget.isYearly,
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
);
Navigator.of(context).pop(); // 상세 화면 닫기
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}
@@ -0,0 +1,486 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
import 'subscription_details_screen.dart';
class SubscriptionScreen extends StatefulWidget {
const SubscriptionScreen({super.key});
@override
State<SubscriptionScreen> createState() => _SubscriptionScreenState();
}
class _SubscriptionScreenState extends State<SubscriptionScreen> {
bool _isYearly = true; // 기본값은 연간 구독
@override
void initState() {
super.initState();
// 화면 로드 시 구독 정보 조회
WidgetsBinding.instance.addPostFrameCallback((_) {
final subscriptionService = Provider.of<SubscriptionService>(
context,
listen: false,
);
subscriptionService.fetchCurrentSubscription();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('구독 관리')),
body: Consumer<SubscriptionService>(
builder: (context, subscriptionService, child) {
if (subscriptionService.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (subscriptionService.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류가 발생했습니다', style: TextStyle(color: Colors.red[700])),
const SizedBox(height: 8),
Text(subscriptionService.error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
subscriptionService.fetchCurrentSubscription(),
child: const Text('다시 시도'),
),
],
),
);
}
// 현재 구독 정보가 있는 경우
if (subscriptionService.hasSubscription) {
return _buildCurrentSubscription(subscriptionService);
}
// 구독 정보가 없는 경우 플랜 선택 화면
return _buildSubscriptionPlans(subscriptionService);
},
),
);
}
Widget _buildCurrentSubscription(SubscriptionService subscriptionService) {
final subscription = subscriptionService.currentSubscription!;
final plan = subscriptionService.availablePlans.firstWhere(
(plan) => plan.type == subscription.planType,
orElse: () => SubscriptionPlan.getDefaultPlans().first,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 현재 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: subscription.isActive ? Colors.green : Colors.grey,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.verified,
color: subscription.isActive
? Colors.green
: Colors.grey,
size: 28,
),
const SizedBox(width: 8),
Text(
'${plan.name} 플랜',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: subscription.isActive
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: subscription.isActive
? Colors.green
: Colors.grey,
),
),
child: Text(
subscription.isActive ? '활성' : '만료됨',
style: TextStyle(
color: subscription.isActive
? Colors.green
: Colors.grey,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
_buildInfoRow('시작일', _formatDate(subscription.startDate)),
const SizedBox(height: 8),
_buildInfoRow('만료일', _formatDate(subscription.endDate)),
const SizedBox(height: 8),
_buildInfoRow(
'자동 갱신',
subscription.autoRenew ? '활성화' : '비활성화',
),
const SizedBox(height: 8),
_buildInfoRow('남은 기간', '${subscription.daysLeft}'),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
const Text(
'플랜 혜택',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...plan.features.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
],
),
),
),
const SizedBox(height: 24),
// 구독 관리 버튼
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: subscription.isActive
? () => _showCancelDialog(subscriptionService)
: null,
icon: const Icon(Icons.cancel),
label: const Text('구독 취소'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade100,
foregroundColor: Colors.red.shade700,
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _toggleAutoRenew(subscriptionService),
icon: Icon(
subscription.autoRenew ? Icons.toggle_on : Icons.toggle_off,
),
label: Text(subscription.autoRenew ? '자동 갱신 끄기' : '자동 갱신 켜기'),
),
),
],
),
const SizedBox(height: 16),
// 플랜 변경 버튼
ElevatedButton.icon(
onPressed: () {
setState(() {
// 플랜 선택 화면으로 전환
subscriptionService.fetchCurrentSubscription();
});
},
icon: const Icon(Icons.upgrade),
label: const Text('플랜 변경하기'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
);
}
Widget _buildSubscriptionPlans(SubscriptionService subscriptionService) {
final plans = subscriptionService.availablePlans;
return Column(
children: [
// 연간/월간 선택 토글
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('월간 구독'),
const SizedBox(width: 8),
Switch(
value: _isYearly,
onChanged: (value) {
setState(() {
_isYearly = value;
});
},
activeColor: Colors.blue,
),
const SizedBox(width: 8),
Row(
children: [
const Text('연간 구독'),
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green),
),
child: const Text(
'20% 할인',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// 플랜 목록
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
final price = _isYearly ? plan.yearlyPrice : plan.monthlyPrice;
final period = _isYearly ? '' : '';
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SubscriptionDetailsScreen(
plan: plan,
isYearly: _isYearly,
),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
plan.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (plan.type == SubscriptionPlanType.premium)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기',
style: TextStyle(
color: Colors.amber,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(plan.description),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text('/$period'),
const Spacer(),
const Icon(Icons.arrow_forward),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
...plan.features
.take(3)
.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
if (plan.features.length > 3)
Text(
'${plan.features.length - 3}개 혜택',
style: TextStyle(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
),
),
);
},
),
),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Row(
children: [
Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)),
Text(value),
],
);
}
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
void _showCancelDialog(SubscriptionService subscriptionService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('구독 취소'),
content: const Text(
'정말로 구독을 취소하시겠습니까?\n'
'취소하면 현재 구독 기간이 끝날 때까지만 서비스를 이용할 수 있습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('아니오'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final success = await subscriptionService.cancelSubscription();
if (success && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('구독이 취소되었습니다')));
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('예, 취소합니다'),
),
],
),
);
}
void _toggleAutoRenew(SubscriptionService subscriptionService) async {
final success = await subscriptionService.toggleAutoRenew();
if (success && mounted) {
final isAutoRenew = subscriptionService.currentSubscription!.autoRenew;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
),
);
}
}
}