forked from baron/baron-sso
Apply stashed changes after rebase
This commit is contained in:
@@ -3304,7 +3304,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(client.Metadata),
|
||||
URL: clientURL,
|
||||
Status: hydraClientStatus(client.Metadata),
|
||||
Status: "active", // Hydra 세션이 있으면 활성
|
||||
Scopes: scopes,
|
||||
},
|
||||
lastAuth: lastAuth,
|
||||
@@ -3327,6 +3327,65 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
|
||||
if h.ConsentRepo != nil {
|
||||
for _, subject := range subjects {
|
||||
dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject)
|
||||
if err != nil {
|
||||
slog.Error("failed to list db consents for subject", "subject", subject, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dc := range dbConsents {
|
||||
if _, exists := records[dc.ClientID]; exists {
|
||||
// 이미 Hydra 세션으로 존재하면 skip (active 우선)
|
||||
continue
|
||||
}
|
||||
|
||||
// Hydra에서 클라이언트 정보 조회 (메타데이터용)
|
||||
client, err := h.Hydra.GetClient(c.Context(), dc.ClientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err)
|
||||
// Hydra에 정보가 없더라도 기본 정보로 추가
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: dc.ClientID,
|
||||
Name: dc.ClientID,
|
||||
Status: "inactive",
|
||||
Scopes: dc.GrantedScopes,
|
||||
},
|
||||
lastAuth: dc.UpdatedAt,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
if name == "" {
|
||||
name = client.ClientID
|
||||
}
|
||||
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
records[dc.ClientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: dc.ClientID,
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(client.Metadata),
|
||||
URL: clientURL,
|
||||
Status: "inactive",
|
||||
Scopes: dc.GrantedScopes,
|
||||
},
|
||||
lastAuth: dc.UpdatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]*linkedRpRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
ordered = append(ordered, record)
|
||||
@@ -3336,7 +3395,10 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
})
|
||||
|
||||
items := make([]linkedRpSummary, 0, len(ordered))
|
||||
for _, record := range ordered {
|
||||
for i, record := range ordered {
|
||||
if i >= 100 {
|
||||
break
|
||||
}
|
||||
if !record.lastAuth.IsZero() {
|
||||
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type ClientConsentRepository interface {
|
||||
Delete(ctx context.Context, subject, clientID string) error
|
||||
List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error)
|
||||
ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error)
|
||||
}
|
||||
|
||||
type clientConsentRepo struct {
|
||||
@@ -90,3 +91,12 @@ func (r *clientConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID
|
||||
|
||||
return consents, total, err
|
||||
}
|
||||
|
||||
func (r *clientConsentRepo) ListBySubject(ctx context.Context, subject string) ([]domain.ClientConsent, error) {
|
||||
var consents []domain.ClientConsent
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("subject = ?", subject).
|
||||
Order("updated_at DESC").
|
||||
Find(&consents).Error
|
||||
return consents, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -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:url_launcher/url_launcher.dart';
|
||||
import '../domain/providers/linked_rps_provider.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
@@ -12,6 +13,97 @@ import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../domain/dashboard_providers.dart';
|
||||
import '../domain/models.dart';
|
||||
|
||||
class AuditLogEntry {
|
||||
final String eventId;
|
||||
final DateTime timestamp;
|
||||
final String userId;
|
||||
final String eventType;
|
||||
final String status;
|
||||
final String authMethod;
|
||||
final String ipAddress;
|
||||
final String userAgent;
|
||||
final String sessionId;
|
||||
final String details;
|
||||
final String source;
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final String parentSessionId;
|
||||
|
||||
AuditLogEntry({
|
||||
required this.eventId,
|
||||
required this.timestamp,
|
||||
required this.userId,
|
||||
required this.eventType,
|
||||
required this.status,
|
||||
required this.authMethod,
|
||||
required this.ipAddress,
|
||||
required this.userAgent,
|
||||
required this.sessionId,
|
||||
required this.details,
|
||||
required this.source,
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.parentSessionId,
|
||||
});
|
||||
|
||||
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
||||
final timestampRaw = json['timestamp']?.toString() ?? '';
|
||||
DateTime parsedTimestamp;
|
||||
try {
|
||||
parsedTimestamp = DateTime.parse(timestampRaw).toLocal();
|
||||
} catch (_) {
|
||||
parsedTimestamp = DateTime.now();
|
||||
}
|
||||
|
||||
return AuditLogEntry(
|
||||
eventId: json['event_id'] ?? '',
|
||||
timestamp: parsedTimestamp,
|
||||
userId: json['user_id'] ?? '',
|
||||
eventType: json['event_type'] ?? '',
|
||||
status: json['status'] ?? '',
|
||||
authMethod: json['auth_method'] ?? '',
|
||||
ipAddress: json['ip_address'] ?? '',
|
||||
userAgent: json['user_agent'] ?? '',
|
||||
sessionId: json['session_id'] ?? '',
|
||||
details: json['details'] ?? '',
|
||||
source: json['source'] ?? '',
|
||||
clientId: json['client_id'] ?? '',
|
||||
appName: json['app_name'] ?? '',
|
||||
parentSessionId: json['parent_session_id'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> get detailMap {
|
||||
if (details.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return jsonDecode(details) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
String get path {
|
||||
final detailPath = detailMap['path']?.toString();
|
||||
if (detailPath != null && detailPath.isNotEmpty) {
|
||||
return detailPath;
|
||||
}
|
||||
final parts = eventType.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return parts.sublist(1).join(' ');
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
class _AuditPage {
|
||||
final List<AuditLogEntry> items;
|
||||
final String? nextCursor;
|
||||
|
||||
const _AuditPage({required this.items, this.nextCursor});
|
||||
}
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@@ -26,7 +118,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
static const _subtle = Color(0xFFF7F8FA);
|
||||
|
||||
final ScrollController _pageScrollController = ScrollController();
|
||||
final ScrollController _rpScrollController = ScrollController();
|
||||
final List<AuditLogEntry> _auditLogs = [];
|
||||
String? _auditNextCursor;
|
||||
bool _auditLoading = false;
|
||||
bool _auditLoadingMore = false;
|
||||
String? _auditError;
|
||||
bool _isRevoking = false;
|
||||
|
||||
bool _showAllActivities = false;
|
||||
final Set<String> _revokedClientIds = {};
|
||||
|
||||
@@ -34,11 +133,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageScrollController.addListener(_onPageScroll);
|
||||
_loadAuditLogs(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageScrollController.dispose();
|
||||
_rpScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -71,7 +172,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
|
||||
setState(() => _isRevoking = true);
|
||||
try {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
await ref.read(linkedRpsProvider.notifier).revokeRp(clientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$appName 연동이 해지되었습니다.')),
|
||||
@@ -264,6 +365,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
ref.read(authTimelineProvider.notifier).refresh(),
|
||||
]);
|
||||
}
|
||||
await _loadAuditLogs(reset: true);
|
||||
await ref.read(linkedRpsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
if (!dotenv.isInitialized) {
|
||||
return fallback;
|
||||
}
|
||||
return dotenv.env[key] ?? fallback;
|
||||
}
|
||||
|
||||
Future<_AuditPage> _fetchAuditLogs({String? cursor}) async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final queryParameters = <String, String>{
|
||||
'limit': '20',
|
||||
};
|
||||
if (cursor != null && cursor.isNotEmpty) {
|
||||
queryParameters['cursor'] = cursor;
|
||||
}
|
||||
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
|
||||
.replace(queryParameters: queryParameters);
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load audit logs');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final nextCursor = body['next_cursor']?.toString();
|
||||
final logs = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AuditLogEntry.fromJson)
|
||||
.toList();
|
||||
|
||||
return _AuditPage(items: logs, nextCursor: nextCursor);
|
||||
}
|
||||
|
||||
Future<void> _loadAuditLogs({bool reset = false}) async {
|
||||
if (_auditLoading || _auditLoadingMore) {
|
||||
return;
|
||||
}
|
||||
if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
setState(() {
|
||||
_auditLogs.clear();
|
||||
_auditNextCursor = null;
|
||||
_auditError = null;
|
||||
_auditLoading = true;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_auditLoadingMore = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final page = await _fetchAuditLogs(cursor: _auditNextCursor);
|
||||
setState(() {
|
||||
_auditLogs.addAll(page.items);
|
||||
_auditNextCursor = page.nextCursor;
|
||||
_auditError = null;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() {
|
||||
_auditError = '접속이력을 불러오지 못했습니다.';
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_auditLoading = false;
|
||||
_auditLoadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
@@ -560,14 +750,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_buildHeaderCard(userName, department, sessionIssuedAt),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
_buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||
_buildSectionTitle('나의 App 현황', '현재 연결된 앱과 최근 인증 상태입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildActivitySection(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPastRps(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('접속이력', 'Baron 로그인 기준의 최근 접근 기록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildAccessHistory(timelineState, timelineWide),
|
||||
@@ -670,31 +856,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActivitySection(bool isMobile) {
|
||||
final linkedState = ref.watch(linkedRpsProvider);
|
||||
return linkedState.when(
|
||||
loading: () => const SizedBox(height: 40, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'연동 정보를 불러오지 못했습니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
final linkedRpsState = ref.watch(linkedRpsProvider);
|
||||
|
||||
return linkedRpsState.when(
|
||||
data: (linkedRps) {
|
||||
final activities = _buildActivityItems(linkedRps);
|
||||
final grid = _buildActivityGrid(activities, isMobile);
|
||||
|
||||
if (activities.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'연동된 RP가 없습니다.',
|
||||
'연동된 앱이 없습니다.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'RP를 연동하면 최근 활동과 상태가 표시됩니다.',
|
||||
'앱을 연동하면 최근 활동과 상태가 표시됩니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
@@ -702,6 +881,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
return _buildActivityGrid(activities, isMobile);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'연동 정보를 불러오지 못했습니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => ref.read(linkedRpsProvider.notifier).refresh(),
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -751,14 +948,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||
final items = <_ActivityItem>[];
|
||||
for (final rp in linkedRps) {
|
||||
final isRevoked = _revokedClientIds.contains(rp.id);
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
||||
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
|
||||
final isRevoked = !isActiveInApi;
|
||||
|
||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
||||
: '연동됨';
|
||||
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성');
|
||||
final statusLabel = isRevoked ? '해지됨' : '활성';
|
||||
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
||||
|
||||
items.add(
|
||||
_ActivityItem(
|
||||
clientId: rp.id,
|
||||
@@ -769,32 +970,44 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
canLogout: false,
|
||||
isRevoked: isRevoked,
|
||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||
url: rp.url, // URL 전달
|
||||
url: rp.url,
|
||||
lastAuthDateTime: rp.lastAuthenticatedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 로직 적용: 활성 우선 -> 최근 인증 최신순 -> 비활성
|
||||
items.sort((a, b) {
|
||||
final aActive = a.status == '활성';
|
||||
final bActive = b.status == '활성';
|
||||
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
|
||||
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
|
||||
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
|
||||
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
|
||||
}
|
||||
if (a.lastAuthDateTime != null) return -1;
|
||||
if (b.lastAuthDateTime != null) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||
if (activities.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
if (!isMobile) {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: activities.map(_buildActivityCard).toList(),
|
||||
);
|
||||
}
|
||||
final shouldShowToggle = activities.length > 5;
|
||||
|
||||
final visibleCount = _showAllActivities ? activities.length : 4;
|
||||
final visibleActivities = activities.take(visibleCount).toList();
|
||||
final shouldShowToggle = activities.length > 4;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GridView.builder(
|
||||
// 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap)
|
||||
if (!_showAllActivities) {
|
||||
final visibleActivities = activities.take(5).toList();
|
||||
Widget grid;
|
||||
if (isMobile) {
|
||||
grid = GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
@@ -805,23 +1018,117 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
itemCount: visibleActivities.length,
|
||||
itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]),
|
||||
),
|
||||
if (shouldShowToggle)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showAllActivities = !_showAllActivities;
|
||||
});
|
||||
},
|
||||
child: Text(_showAllActivities ? '접기' : '더보기'),
|
||||
);
|
||||
} else {
|
||||
grid = Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: visibleActivities.map(_buildActivityCard).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
grid,
|
||||
if (shouldShowToggle)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _showAllActivities = true),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.blueAccent),
|
||||
label: const Text('더보기', style: TextStyle(color: Colors.blueAccent, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 더보기를 누른 경우: 가로 슬라이더/캐러셀 전환
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ListView.separated(
|
||||
controller: _rpScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: activities.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => UnconstrainedBox(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _buildActivityCard(activities[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 왼쪽 이동 버튼
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: _buildScrollButton(
|
||||
icon: Icons.chevron_left,
|
||||
onPressed: () => _rpScrollController.animateTo(
|
||||
(_rpScrollController.offset - 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 오른쪽 이동 버튼
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: _buildScrollButton(
|
||||
icon: Icons.chevron_right,
|
||||
onPressed: () => _rpScrollController.animateTo(
|
||||
(_rpScrollController.offset + 300).clamp(0, _rpScrollController.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_showAllActivities = false;
|
||||
_rpScrollController.jumpTo(0); // 접을 때 위치 초기화
|
||||
}),
|
||||
icon: const Icon(Icons.close, size: 18, color: Colors.grey),
|
||||
label: const Text('접기', style: TextStyle(color: Colors.grey, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollButton({required IconData icon, required VoidCallback onPressed}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: _ink),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityCard(_ActivityItem item) {
|
||||
final isActive = item.status == '활성';
|
||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||
@@ -1172,6 +1479,7 @@ class _ActivityItem {
|
||||
final bool isRevoked;
|
||||
final VoidCallback? onLogout;
|
||||
final VoidCallback? onRevoke;
|
||||
final DateTime? lastAuthDateTime;
|
||||
|
||||
_ActivityItem({
|
||||
required this.clientId,
|
||||
@@ -1184,5 +1492,6 @@ class _ActivityItem {
|
||||
this.isRevoked = false,
|
||||
this.onLogout,
|
||||
this.onRevoke,
|
||||
this.lastAuthDateTime,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user