diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 653a6697..737b534a 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; @@ -106,6 +107,7 @@ class LinkedRp { final String id; final String name; final String logo; + final String url; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -114,6 +116,7 @@ class LinkedRp { required this.id, required this.name, required this.logo, + required this.url, required this.status, required this.scopes, required this.lastAuthenticatedAt, @@ -134,6 +137,7 @@ class LinkedRp { id: json['id']?.toString() ?? '', name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', + url: json['url']?.toString() ?? '', status: json['status']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, @@ -887,6 +891,7 @@ class _DashboardScreenState extends ConsumerState { canLogout: false, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), + url: rp.url, // URL 전달 ), ); } @@ -944,97 +949,136 @@ class _DashboardScreenState extends ConsumerState { final statusColor = isActive ? Colors.green : Colors.grey; final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border; final borderWidth = isActive ? 1.5 : 1.0; + + // 활성 상태면 클릭 가능 (URL 유무와 관계없이) + final isClickable = isActive; - return Opacity( - opacity: item.isRevoked ? 0.6 : 1.0, - child: Container( - width: 260, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _surface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: borderColor, width: borderWidth), - boxShadow: isActive ? [ - BoxShadow( - color: Colors.green.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ) - ] : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - item.appName, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink), - ), + // 카드 컨텐츠 + final cardContent = Container( + width: 260, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: borderWidth), + boxShadow: isActive ? [ + BoxShadow( + color: Colors.green.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ) + ] : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.appName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - item.status, - style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), - ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), ), - ], - ), - const SizedBox(height: 12), - Text( - '최근 인증', - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - const SizedBox(height: 4), - Text( - item.lastAuthAt, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink), - ), - const SizedBox(height: 16), - Row( - children: [ - if (item.canLogout) - Expanded( - child: OutlinedButton( - onPressed: item.onLogout, - style: OutlinedButton.styleFrom( - foregroundColor: _ink, - side: const BorderSide(color: _border), - padding: const EdgeInsets.symmetric(vertical: 8), - ), - child: const Text('로그아웃', style: TextStyle(fontSize: 13)), - ), - ), - if (item.canLogout) const SizedBox(width: 8), + child: Text( + item.status, + style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '최근 인증', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 4), + Text( + item.lastAuthAt, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink), + ), + const SizedBox(height: 16), + Row( + children: [ + if (item.canLogout) Expanded( child: OutlinedButton( - onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, + onPressed: item.onLogout, style: OutlinedButton.styleFrom( - foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent, - side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5), + foregroundColor: _ink, + side: const BorderSide(color: _border), padding: const EdgeInsets.symmetric(vertical: 8), ), - child: _isRevoking && !item.isRevoked - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent), - ) - : Text(item.isRevoked ? '해지됨' : '연동 해지', style: const TextStyle(fontSize: 13)), + child: const Text('로그아웃', style: TextStyle(fontSize: 13)), ), ), - ], - ), - ], - ), + if (item.canLogout) const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke, + style: OutlinedButton.styleFrom( + foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent, + side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + child: _isRevoking && !item.isRevoked + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent), + ) + : Text(item.isRevoked ? '해지됨' : '연동 해지', style: const TextStyle(fontSize: 13)), + ), + ), + ], + ), + ], ), ); + + // Opacity 적용 + final opaqueCard = Opacity( + opacity: item.isRevoked ? 0.6 : 1.0, + child: cardContent, + ); + + // 클릭 가능한 경우 InkWell로 감싸기 + if (isClickable) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + if (item.url != null && item.url!.isNotEmpty) { + final uri = Uri.parse(item.url!); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('해당 링크를 열 수 없습니다.')), + ); + } + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')), + ); + } + } + }, + child: opaqueCard, + ), + ); + } + + return opaqueCard; } Widget _buildAccessHistory(bool isWide) { @@ -1232,6 +1276,7 @@ class _ActivityItem { final String appName; final String lastAuthAt; final String status; + final String? url; final bool canLogout; final bool isRevoked; final VoidCallback? onLogout; @@ -1243,8 +1288,9 @@ class _ActivityItem { required this.lastAuthAt, required this.status, required this.canLogout, + this.url, this.isRevoked = false, this.onLogout, this.onRevoke, }); -} \ No newline at end of file +}