forked from baron/baron-sso
앱 현황 섹션 통합 및 Linked API 기반 UI 개편
This commit is contained in:
@@ -28,38 +28,7 @@ Future<List<LinkedRp>> _fetchLinkedRps() async {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RpHistoryItem>> _fetchRpHistory() async {
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/rp/history');
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await client.get(url, headers: headers);
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('Failed to load rp history');
|
|
||||||
}
|
|
||||||
|
|
||||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final items = (body['items'] as List?) ?? [];
|
|
||||||
final result = <RpHistoryItem>[];
|
|
||||||
for (final item in items) {
|
|
||||||
if (item is Map) {
|
|
||||||
result.add(RpHistoryItem.fromJson(Map<String, dynamic>.from(item)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{
|
||||||
@@ -104,13 +73,9 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final linkedRpsProvider = FutureProvider<List<LinkedRp>>((ref) async {
|
|
||||||
return _fetchLinkedRps();
|
|
||||||
});
|
|
||||||
|
|
||||||
final rpHistoryProvider = FutureProvider<List<RpHistoryItem>>((ref) async {
|
|
||||||
return _fetchRpHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||||
|
import 'package:userfront/core/services/auth_token_store.dart';
|
||||||
|
import 'package:userfront/core/services/http_client.dart';
|
||||||
|
|
||||||
|
class LinkedRp {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String logo;
|
||||||
|
final String url;
|
||||||
|
final String status;
|
||||||
|
final List<String> scopes;
|
||||||
|
final DateTime? lastAuthenticatedAt;
|
||||||
|
|
||||||
|
LinkedRp({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.logo,
|
||||||
|
required this.url,
|
||||||
|
required this.status,
|
||||||
|
required this.scopes,
|
||||||
|
required this.lastAuthenticatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? '';
|
||||||
|
DateTime? parsedLastAuth;
|
||||||
|
if (rawLastAuth.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
parsedLastAuth = DateTime.parse(rawLastAuth).toLocal();
|
||||||
|
} catch (_) {
|
||||||
|
parsedLastAuth = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LinkedRp(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '',
|
||||||
|
logo: json['logo']?.toString() ?? '',
|
||||||
|
url: json['url']?.toString() ?? '',
|
||||||
|
status: json['status']?.toString() ?? 'unknown',
|
||||||
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
|
lastAuthenticatedAt: parsedLastAuth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
||||||
|
@override
|
||||||
|
Future<List<LinkedRp>> build() async {
|
||||||
|
return _fetchLinkedRps();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _envOrDefault(String key, String fallback) {
|
||||||
|
if (!dotenv.isInitialized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return dotenv.env[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LinkedRp>> _fetchLinkedRps() async {
|
||||||
|
try {
|
||||||
|
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
|
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
|
||||||
|
|
||||||
|
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 linked rps: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final items = (body['items'] as List?) ?? [];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(LinkedRp.fromJson)
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(() => _fetchLinkedRps());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> revokeRp(String clientId) async {
|
||||||
|
await AuthProxyService.revokeLinkedRp(clientId);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||||
|
return LinkedRpsNotifier();
|
||||||
|
});
|
||||||
@@ -4,105 +4,16 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import '../domain/providers/linked_rps_provider.dart';
|
import '../domain/providers/linked_rps_provider.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../../core/services/http_client.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../domain/dashboard_providers.dart';
|
import '../domain/dashboard_providers.dart';
|
||||||
import '../domain/models.dart';
|
import '../domain/models.dart' hide LinkedRp;
|
||||||
|
|
||||||
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 {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
@@ -181,7 +92,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_revokedClientIds.add(clientId);
|
_revokedClientIds.add(clientId);
|
||||||
});
|
});
|
||||||
ref.invalidate(linkedRpsProvider);
|
ref.invalidate(linkedRpsProvider);
|
||||||
ref.invalidate(rpHistoryProvider);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -214,7 +124,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Consumer(
|
builder: (context) => Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final historyState = ref.watch(rpHistoryProvider);
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(item.appName),
|
title: Text(item.appName),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
@@ -240,48 +149,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
|
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
historyState.when(
|
Column(
|
||||||
loading: () => const SizedBox(height: 20, child: LinearProgressIndicator()),
|
|
||||||
error: (_, __) => const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey)),
|
|
||||||
data: (history) {
|
|
||||||
final filtered = history.where((h) => h.clientId == item.clientId).toList();
|
|
||||||
if (filtered.isEmpty) {
|
|
||||||
return Text('최근 인증: ${item.lastAuthAt}');
|
|
||||||
}
|
|
||||||
final h = filtered.first;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (h.lastApprovedAt != null)
|
Text('최근 인증: ${item.lastAuthAt}'),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (h.lastRevokedAt != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cancel_outlined, size: 16, color: Colors.redAccent),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'현재 상태: ${h.status == 'active' ? '활성' : '해지됨'}',
|
'현재 상태: ${item.status}',
|
||||||
style: TextStyle(color: h.status == 'active' ? Colors.green : Colors.grey),
|
style: TextStyle(color: item.status == '활성' ? Colors.green : Colors.grey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -356,15 +233,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
_revokedClientIds.clear();
|
_revokedClientIds.clear();
|
||||||
});
|
});
|
||||||
ref.invalidate(linkedRpsProvider);
|
ref.invalidate(linkedRpsProvider);
|
||||||
ref.invalidate(rpHistoryProvider);
|
|
||||||
final linkedFuture = ref.read(linkedRpsProvider.future);
|
await Future.wait([
|
||||||
final historyFuture = ref.read(rpHistoryProvider.future);
|
ref.read(linkedRpsProvider.future),
|
||||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
|
||||||
linkedFuture,
|
|
||||||
historyFuture,
|
|
||||||
ref.read(authTimelineProvider.notifier).refresh(),
|
ref.read(authTimelineProvider.notifier).refresh(),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
await _loadAuditLogs(reset: true);
|
await _loadAuditLogs(reset: true);
|
||||||
await ref.read(linkedRpsProvider.notifier).refresh();
|
await ref.read(linkedRpsProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
@@ -376,7 +250,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return dotenv.env[key] ?? fallback;
|
return dotenv.env[key] ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_AuditPage> _fetchAuditLogs({String? cursor}) async {
|
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
||||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{
|
||||||
'limit': '20',
|
'limit': '20',
|
||||||
@@ -397,9 +271,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final response = await client.get(url, headers: headers);
|
final response = await client.get(url, headers: headers);
|
||||||
client.close();
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to load audit logs');
|
throw Exception('Failed to load audit logs');
|
||||||
}
|
}
|
||||||
@@ -412,7 +285,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
.map(AuditLogEntry.fromJson)
|
.map(AuditLogEntry.fromJson)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return _AuditPage(items: logs, nextCursor: nextCursor);
|
return AuditPage(items: logs, nextCursor: nextCursor);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAuditLogs({bool reset = false}) async {
|
Future<void> _loadAuditLogs({bool reset = false}) async {
|
||||||
@@ -902,48 +778,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPastRps(bool isMobile) {
|
|
||||||
final historyState = ref.watch(rpHistoryProvider);
|
|
||||||
return historyState.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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
data: (history) {
|
|
||||||
final pastItems = history.where((h) => h.status != 'active').toList();
|
|
||||||
if (pastItems.isEmpty) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'과거 연동 이력이 없습니다.',
|
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final activities = pastItems.map((h) => _ActivityItem(
|
|
||||||
clientId: h.clientId,
|
|
||||||
appName: h.clientName.isNotEmpty ? h.clientName : h.clientId,
|
|
||||||
lastAuthAt: h.lastRevokedAt != null ? '해지: ${_formatDateTime(h.lastRevokedAt!)}' : '해지됨',
|
|
||||||
status: '해지됨',
|
|
||||||
scopes: h.scopes,
|
|
||||||
canLogout: false,
|
|
||||||
isRevoked: true,
|
|
||||||
onRevoke: null,
|
|
||||||
)).toList();
|
|
||||||
|
|
||||||
return _buildActivityGrid(activities, isMobile);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||||
final items = <_ActivityItem>[];
|
final items = <_ActivityItem>[];
|
||||||
@@ -1000,11 +835,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) {
|
||||||
if (activities.isEmpty) return const SizedBox.shrink();
|
if (activities.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
final shouldShowToggle = activities.length > 5;
|
final shouldShowToggle = activities.length > 4;
|
||||||
|
|
||||||
// 더보기를 누르지 않은 경우: 최대 5개 노출 (Grid/Wrap)
|
// 더보기를 누르지 않은 경우: 최대 4개 노출 (Grid/Wrap)
|
||||||
if (!_showAllActivities) {
|
if (!_showAllActivities) {
|
||||||
final visibleActivities = activities.take(5).toList();
|
final visibleActivities = activities.take(4).toList();
|
||||||
Widget grid;
|
Widget grid;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
grid = GridView.builder(
|
grid = GridView.builder(
|
||||||
|
|||||||
Reference in New Issue
Block a user