import 'package:flutter/material.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/web_window.dart'; class ConsentScreen extends StatefulWidget { final String consentChallenge; const ConsentScreen({super.key, required this.consentChallenge}); @override State createState() => _ConsentScreenState(); } class _ConsentScreenState extends State { static const _ink = Color(0xFF1A1F2C); static const _surface = Colors.white; static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); static const _accent = Color(0xFF2563EB); Map? _consentInfo; bool _isLoading = true; bool _isSubmitting = false; String? _error; @override void initState() { super.initState(); _fetchConsentInfo(); } Future _fetchConsentInfo() async { try { final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); setState(() { _consentInfo = info; _isLoading = false; }); } catch (e) { setState(() { _error = '권한 정보를 불러오지 못했습니다: $e'; _isLoading = false; }); } } Future _acceptConsent() async { setState(() { _isSubmitting = true; _error = null; }); try { final result = await AuthProxyService.acceptConsent(widget.consentChallenge); final redirectTo = result['redirectTo']?.toString() ?? ''; if (redirectTo.isNotEmpty) { if (webWindow.hasOpener() && webWindow.redirectOpenerTo(redirectTo)) { // 팝업에서 호출된 경우, 부모 창으로 리다이렉트 후 현재 창을 닫습니다. webWindow.close(); return; } webWindow.redirectTo(redirectTo); return; } setState(() { _error = '동의는 완료됐지만 이동할 주소를 받지 못했습니다.'; }); } catch (e) { setState(() { _error = '동의 처리 중 오류가 발생했습니다: $e'; }); } finally { if (mounted) { setState(() => _isSubmitting = false); } } } void _rejectConsent() { webWindow.alert('동의를 취소했습니다. 창을 닫아 주세요.'); } Map? _client() { final info = _consentInfo; if (info == null) return null; final client = info['client']; if (client is Map) { return client; } return null; } String _resolveClientName(Map? client) { final name = client?['client_name']?.toString().trim(); if (name != null && name.isNotEmpty) { return name; } final id = client?['client_id']?.toString().trim(); if (id != null && id.isNotEmpty) { return id; } return '알 수 없는 앱'; } String? _resolveClientId(Map? client) { final id = client?['client_id']?.toString().trim(); if (id != null && id.isNotEmpty) { return id; } return null; } String? _resolveClientLogo(Map? client) { final logo = client?['logo_uri']?.toString().trim(); if (logo != null && logo.isNotEmpty) { return logo; } final metadata = client?['metadata']; if (metadata is Map) { final metaLogo = metadata['logo_url']?.toString().trim(); if (metaLogo != null && metaLogo.isNotEmpty) { return metaLogo; } } return null; } List _requestedScopes() { final scopes = _consentInfo?['requested_scope']; if (scopes is List) { return scopes.map((e) => e.toString()).toList(); } return const []; } String _scopeDescription(String scope) { switch (scope) { case 'openid': return '로그인 상태 확인을 위한 기본 식별자'; case 'profile': return '이름, 사용자 식별자 등 기본 프로필 정보'; case 'email': return '이메일 주소 정보'; case 'phone': return '휴대폰 번호 정보'; default: return '앱에서 요청한 추가 권한'; } } Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(999), border: Border.all(color: _border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: _ink), const SizedBox(width: 6), Text( label, style: const TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, ), ), ], ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final client = _client(); final clientName = _resolveClientName(client); final clientId = _resolveClientId(client); final logoUrl = _resolveClientLogo(client); final scopes = _requestedScopes(); return Scaffold( backgroundColor: _subtle, body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Container( decoration: BoxDecoration( color: _surface, borderRadius: BorderRadius.circular(20), border: Border.all(color: _border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Padding( padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), child: _isLoading ? Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text( '권한 정보를 불러오는 중입니다...', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), ), ], ) : _consentInfo == null ? Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '요청 정보를 확인할 수 없습니다.', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.grey[700], ), ), if (_error != null) ...[ const SizedBox(height: 12), Text( _error!, style: theme.textTheme.bodySmall?.copyWith( color: const Color(0xFFB91C1C), ), ), ], const SizedBox(height: 16), OutlinedButton( onPressed: _fetchConsentInfo, style: OutlinedButton.styleFrom( foregroundColor: _ink, side: const BorderSide(color: _border), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: const Text('다시 시도'), ), ], ) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(16), border: Border.all(color: _border), ), child: logoUrl == null ? const Icon( Icons.lock_outline, color: _ink, ) : ClipRRect( borderRadius: BorderRadius.circular(14), child: Image.network( logoUrl, fit: BoxFit.cover, errorBuilder: ( context, error, stackTrace, ) { return const Icon( Icons.lock_outline, color: _ink, ); }, ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '앱 권한 요청', style: theme.textTheme.titleLarge ?.copyWith( fontWeight: FontWeight.w700, color: _ink, ), ), const SizedBox(height: 6), Text( clientName, style: theme.textTheme.titleMedium ?.copyWith( fontWeight: FontWeight.w600, color: _ink, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ if (clientId != null) _buildInfoChip( Icons.vpn_key_outlined, 'Client ID: $clientId', ), _buildInfoChip( Icons.security_outlined, '요청 권한 ${scopes.length}개', ), ], ), ], ), ), ], ), const SizedBox(height: 16), Text( '이 앱이 아래 정보에 접근하려고 합니다. 계속 진행하려면 동의 여부를 선택해 주세요.', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.grey[700], height: 1.5, ), ), if (_error != null) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(12), border: Border.all( color: const Color(0xFFFCA5A5), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon( Icons.error_outline, color: Color(0xFFB91C1C), size: 20, ), const SizedBox(width: 8), Expanded( child: Text( _error!, style: theme.textTheme.bodySmall ?.copyWith( color: const Color(0xFFB91C1C), height: 1.4, ), ), ), ], ), ), ], const SizedBox(height: 20), Text( '요청된 권한', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: _ink, ), ), const SizedBox(height: 12), if (scopes.isEmpty) Text( '요청된 권한 정보가 없습니다.', style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ) else Column( children: scopes .map( (scope) => Container( margin: const EdgeInsets.only( bottom: 10, ), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _subtle, borderRadius: BorderRadius.circular(12), border: Border.all(color: _border), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon( Icons.check_circle_outline, color: _accent, size: 20, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment .start, children: [ Text( scope, style: theme.textTheme .bodyMedium ?.copyWith( fontWeight: FontWeight.w600, color: _ink, ), ), const SizedBox(height: 4), Text( _scopeDescription( scope), style: theme.textTheme .bodySmall ?.copyWith( color: Colors.grey[600], ), ), ], ), ), ], ), ), ) .toList(), ), const SizedBox(height: 12), Text( '동의 후 자동으로 서비스로 이동합니다.', style: theme.textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 20), Wrap( spacing: 12, runSpacing: 12, children: [ OutlinedButton( onPressed: _isSubmitting ? null : _rejectConsent, style: OutlinedButton.styleFrom( foregroundColor: _ink, padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12, ), side: const BorderSide( color: _border, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: const Text('취소'), ), FilledButton( onPressed: _isSubmitting ? null : _acceptConsent, style: FilledButton.styleFrom( backgroundColor: _ink, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: _isSubmitting ? Row( mainAxisSize: MainAxisSize.min, children: const [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ), SizedBox(width: 8), Text('처리 중...'), ], ) : const Text('동의하고 계속하기'), ), ], ), ], ), ), ), ), ), ), ), ); } }