Flutter 앱: 전체 화면 MVCS 아키텍처 리팩토링
홈, 멤버, 앨범 화면을 Riverpod 기반 MVCS 패턴으로 리팩토링. View는 UI와 애니메이션만, Controller는 상태와 비즈니스 로직 담당. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc37abe473
commit
671618442c
6 changed files with 408 additions and 189 deletions
73
app/lib/controllers/album_controller.dart
Normal file
73
app/lib/controllers/album_controller.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/// 앨범 컨트롤러 (MVCS의 Controller 레이어)
|
||||
///
|
||||
/// 비즈니스 로직과 상태 관리를 담당합니다.
|
||||
/// View는 이 Controller를 통해 데이터에 접근합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/album.dart';
|
||||
import '../services/albums_service.dart';
|
||||
|
||||
/// 앨범 상태
|
||||
class AlbumState {
|
||||
final List<Album> albums;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AlbumState({
|
||||
this.albums = const [],
|
||||
this.isLoading = true,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// 상태 복사 (불변성 유지)
|
||||
AlbumState copyWith({
|
||||
List<Album>? albums,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AlbumState(
|
||||
albums: albums ?? this.albums,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 앨범 컨트롤러
|
||||
class AlbumController extends Notifier<AlbumState> {
|
||||
@override
|
||||
AlbumState build() {
|
||||
// 초기 데이터 로드
|
||||
Future.microtask(() => loadAlbums());
|
||||
return const AlbumState();
|
||||
}
|
||||
|
||||
/// 앨범 목록 로드
|
||||
Future<void> loadAlbums() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final albums = await getAlbums();
|
||||
state = state.copyWith(
|
||||
albums: albums,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 앨범 새로고침
|
||||
Future<void> refresh() async {
|
||||
await loadAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
/// 앨범 Provider
|
||||
final albumProvider = NotifierProvider<AlbumController, AlbumState>(
|
||||
AlbumController.new,
|
||||
);
|
||||
93
app/lib/controllers/home_controller.dart
Normal file
93
app/lib/controllers/home_controller.dart
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/// 홈 컨트롤러 (MVCS의 Controller 레이어)
|
||||
///
|
||||
/// 비즈니스 로직과 상태 관리를 담당합니다.
|
||||
/// View는 이 Controller를 통해 데이터에 접근합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/member.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/schedule.dart';
|
||||
import '../services/members_service.dart';
|
||||
import '../services/albums_service.dart';
|
||||
import '../services/schedules_service.dart';
|
||||
|
||||
/// 홈 상태
|
||||
class HomeState {
|
||||
final List<Member> members;
|
||||
final List<Album> albums;
|
||||
final List<Schedule> schedules;
|
||||
final bool isLoading;
|
||||
final bool dataLoaded;
|
||||
final String? error;
|
||||
|
||||
const HomeState({
|
||||
this.members = const [],
|
||||
this.albums = const [],
|
||||
this.schedules = const [],
|
||||
this.isLoading = true,
|
||||
this.dataLoaded = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// 상태 복사 (불변성 유지)
|
||||
HomeState copyWith({
|
||||
List<Member>? members,
|
||||
List<Album>? albums,
|
||||
List<Schedule>? schedules,
|
||||
bool? isLoading,
|
||||
bool? dataLoaded,
|
||||
String? error,
|
||||
}) {
|
||||
return HomeState(
|
||||
members: members ?? this.members,
|
||||
albums: albums ?? this.albums,
|
||||
schedules: schedules ?? this.schedules,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
dataLoaded: dataLoaded ?? this.dataLoaded,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 홈 컨트롤러
|
||||
class HomeController extends Notifier<HomeState> {
|
||||
@override
|
||||
HomeState build() {
|
||||
// 초기 데이터 로드
|
||||
Future.microtask(() => loadData());
|
||||
return const HomeState();
|
||||
}
|
||||
|
||||
/// 데이터 로드 (멤버, 앨범, 일정)
|
||||
Future<void> loadData() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
getActiveMembers(),
|
||||
getRecentAlbums(2),
|
||||
getUpcomingSchedules(3),
|
||||
]);
|
||||
|
||||
state = state.copyWith(
|
||||
members: results[0] as List<Member>,
|
||||
albums: results[1] as List<Album>,
|
||||
schedules: results[2] as List<Schedule>,
|
||||
isLoading: false,
|
||||
dataLoaded: true,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
dataLoaded: true,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 홈 Provider
|
||||
final homeProvider = NotifierProvider<HomeController, HomeState>(
|
||||
HomeController.new,
|
||||
);
|
||||
111
app/lib/controllers/members_controller.dart
Normal file
111
app/lib/controllers/members_controller.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/// 멤버 컨트롤러 (MVCS의 Controller 레이어)
|
||||
///
|
||||
/// 비즈니스 로직과 상태 관리를 담당합니다.
|
||||
/// View는 이 Controller를 통해 데이터에 접근합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/member.dart';
|
||||
import '../services/members_service.dart';
|
||||
|
||||
/// 멤버 상태
|
||||
class MembersState {
|
||||
final List<Member> members;
|
||||
final int currentIndex;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const MembersState({
|
||||
this.members = const [],
|
||||
this.currentIndex = 0,
|
||||
this.isLoading = true,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// 상태 복사 (불변성 유지)
|
||||
MembersState copyWith({
|
||||
List<Member>? members,
|
||||
int? currentIndex,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return MembersState(
|
||||
members: members ?? this.members,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 선택된 멤버
|
||||
Member? get currentMember =>
|
||||
members.isNotEmpty ? members[currentIndex] : null;
|
||||
}
|
||||
|
||||
/// 멤버 컨트롤러
|
||||
class MembersController extends Notifier<MembersState> {
|
||||
@override
|
||||
MembersState build() {
|
||||
// 초기 데이터 로드
|
||||
Future.microtask(() => loadMembers());
|
||||
return const MembersState();
|
||||
}
|
||||
|
||||
/// 멤버 목록 로드
|
||||
Future<void> loadMembers() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final members = await getMembers();
|
||||
// 현재 멤버 먼저, 전 멤버 나중에 정렬
|
||||
members.sort((a, b) {
|
||||
if (a.isFormer != b.isFormer) {
|
||||
return a.isFormer ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
state = state.copyWith(
|
||||
members: members,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 인덱스 변경
|
||||
void setCurrentIndex(int index) {
|
||||
if (index >= 0 && index < state.members.length) {
|
||||
state = state.copyWith(currentIndex: index);
|
||||
}
|
||||
}
|
||||
|
||||
/// 나이 계산
|
||||
int? calculateAge(String? birthDate) {
|
||||
if (birthDate == null) return null;
|
||||
final birth = DateTime.tryParse(birthDate);
|
||||
if (birth == null) return null;
|
||||
final today = DateTime.now();
|
||||
int age = today.year - birth.year;
|
||||
if (today.month < birth.month ||
|
||||
(today.month == birth.month && today.day < birth.day)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
/// 생일 포맷팅
|
||||
String formatBirthDate(String? birthDate) {
|
||||
if (birthDate == null) return '';
|
||||
return birthDate.substring(0, 10).replaceAll('-', '.');
|
||||
}
|
||||
}
|
||||
|
||||
/// 멤버 Provider
|
||||
final membersProvider = NotifierProvider<MembersController, MembersState>(
|
||||
MembersController.new,
|
||||
);
|
||||
|
|
@ -1,29 +1,30 @@
|
|||
/// 앨범 목록 화면
|
||||
/// 앨범 목록 화면 (MVCS의 View 레이어)
|
||||
///
|
||||
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/album.dart';
|
||||
import '../../services/albums_service.dart';
|
||||
import '../../controllers/album_controller.dart';
|
||||
|
||||
class AlbumView extends StatefulWidget {
|
||||
class AlbumView extends ConsumerStatefulWidget {
|
||||
const AlbumView({super.key});
|
||||
|
||||
@override
|
||||
State<AlbumView> createState() => _AlbumViewState();
|
||||
ConsumerState<AlbumView> createState() => _AlbumViewState();
|
||||
}
|
||||
|
||||
class _AlbumViewState extends State<AlbumView> {
|
||||
late Future<List<Album>> _albumsFuture;
|
||||
class _AlbumViewState extends ConsumerState<AlbumView> {
|
||||
bool _initialLoadComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumsFuture = getAlbums();
|
||||
// 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함
|
||||
Future.delayed(const Duration(milliseconds: 600), () {
|
||||
if (mounted) {
|
||||
|
|
@ -34,10 +35,9 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Album>>(
|
||||
future: _albumsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
final albumState = ref.watch(albumProvider);
|
||||
|
||||
if (albumState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
|
|
@ -45,7 +45,7 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
if (albumState.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
@ -66,9 +66,7 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_albumsFuture = getAlbums();
|
||||
});
|
||||
ref.read(albumProvider.notifier).refresh();
|
||||
},
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
|
|
@ -77,8 +75,6 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
);
|
||||
}
|
||||
|
||||
final albums = snapshot.data ?? [];
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
clipBehavior: Clip.none,
|
||||
|
|
@ -88,9 +84,9 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
itemCount: albums.length,
|
||||
itemCount: albumState.albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
final album = albumState.albums[index];
|
||||
return _AlbumCard(
|
||||
album: album,
|
||||
index: index,
|
||||
|
|
@ -98,8 +94,6 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,25 @@
|
|||
/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션
|
||||
/// 홈 화면 (MVCS의 View 레이어)
|
||||
///
|
||||
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/member.dart';
|
||||
import '../../models/album.dart';
|
||||
import '../../models/schedule.dart';
|
||||
import '../../services/members_service.dart';
|
||||
import '../../services/albums_service.dart';
|
||||
import '../../services/schedules_service.dart';
|
||||
import '../../controllers/home_controller.dart';
|
||||
|
||||
class HomeView extends StatefulWidget {
|
||||
class HomeView extends ConsumerStatefulWidget {
|
||||
const HomeView({super.key});
|
||||
|
||||
@override
|
||||
State<HomeView> createState() => _HomeViewState();
|
||||
ConsumerState<HomeView> createState() => _HomeViewState();
|
||||
}
|
||||
|
||||
class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||
List<Member> _members = [];
|
||||
List<Album> _albums = [];
|
||||
List<Schedule> _schedules = [];
|
||||
bool _isLoading = true;
|
||||
bool _dataLoaded = false;
|
||||
|
||||
class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMixin {
|
||||
// 애니메이션 컨트롤러
|
||||
late AnimationController _animController;
|
||||
|
||||
|
|
@ -43,12 +36,12 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
|
||||
// 현재 경로 추적 (홈 탭 선택 시 애니메이션 재시작용)
|
||||
String? _previousPath;
|
||||
bool _animationStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
|
|
@ -146,9 +139,10 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
super.didChangeDependencies();
|
||||
// go_router에서 현재 경로 감지
|
||||
final currentPath = GoRouterState.of(context).uri.path;
|
||||
final homeState = ref.read(homeProvider);
|
||||
|
||||
// 다른 탭에서 홈('/')으로 돌아왔을 때 애니메이션 재시작
|
||||
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && _dataLoaded) {
|
||||
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && homeState.dataLoaded) {
|
||||
_startAnimations();
|
||||
}
|
||||
_previousPath = currentPath;
|
||||
|
|
@ -160,36 +154,19 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
getActiveMembers(),
|
||||
getRecentAlbums(2),
|
||||
getUpcomingSchedules(3),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
_members = results[0] as List<Member>;
|
||||
_albums = results[1] as List<Album>;
|
||||
_schedules = results[2] as List<Schedule>;
|
||||
_isLoading = false;
|
||||
_dataLoaded = true;
|
||||
});
|
||||
|
||||
// 데이터 로드 완료 후 애니메이션 시작
|
||||
_startAnimations();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_dataLoaded = true;
|
||||
});
|
||||
_startAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
final homeState = ref.watch(homeProvider);
|
||||
|
||||
// 데이터 로드 완료 시 애니메이션 시작
|
||||
if (homeState.dataLoaded && !_animationStarted) {
|
||||
_animationStarted = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startAnimations();
|
||||
});
|
||||
}
|
||||
|
||||
if (homeState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
|
|
@ -202,9 +179,9 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
child: Column(
|
||||
children: [
|
||||
_buildHeroSection(),
|
||||
_buildMembersSection(),
|
||||
_buildAlbumsSection(),
|
||||
_buildSchedulesSection(),
|
||||
_buildMembersSection(homeState),
|
||||
_buildAlbumsSection(homeState),
|
||||
_buildSchedulesSection(homeState),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -305,7 +282,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 멤버 섹션 - px-4(16px) py-6(24px)
|
||||
Widget _buildMembersSection() {
|
||||
Widget _buildMembersSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _membersSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -319,7 +296,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
const SizedBox(height: 16),
|
||||
// grid-cols-5 gap-2(8px)
|
||||
Row(
|
||||
children: _members.asMap().entries.map((entry) {
|
||||
children: homeState.members.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final member = entry.value;
|
||||
// 멤버 아이템: delay 0.4+index*0.05s, duration 0.3s
|
||||
|
|
@ -361,7 +338,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
? CachedNetworkImage(
|
||||
imageUrl: member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -389,7 +366,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 앨범 섹션 - px-4(16px) py-6(24px)
|
||||
Widget _buildAlbumsSection() {
|
||||
Widget _buildAlbumsSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _albumsSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -403,7 +380,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
const SizedBox(height: 16),
|
||||
// grid-cols-2 gap-3(12px)
|
||||
Row(
|
||||
children: _albums.asMap().entries.map((entry) {
|
||||
children: homeState.albums.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final album = entry.value;
|
||||
// 앨범 아이템: delay 0.6+index*0.1s, duration 0.3s
|
||||
|
|
@ -457,7 +434,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
? CachedNetworkImage(
|
||||
imageUrl: album.coverThumbUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
: Container(color: Colors.grey[200]),
|
||||
),
|
||||
|
|
@ -500,7 +477,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 일정 섹션 - px-4(16px) py-4(16px)
|
||||
Widget _buildSchedulesSection() {
|
||||
Widget _buildSchedulesSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _schedulesSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -512,7 +489,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
// mb-4(16px)
|
||||
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
|
||||
const SizedBox(height: 16),
|
||||
if (_schedules.isEmpty)
|
||||
if (homeState.schedules.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
|
|
@ -523,7 +500,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
else
|
||||
// space-y-3(12px)
|
||||
Column(
|
||||
children: _schedules.asMap().entries.map((entry) {
|
||||
children: homeState.schedules.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final schedule = entry.value;
|
||||
// 일정 아이템: delay 0.8+index*0.1s, duration 0.3s, x -20→0
|
||||
|
|
@ -544,7 +521,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index < _schedules.length - 1 ? 12 : 0),
|
||||
padding: EdgeInsets.only(bottom: index < homeState.schedules.length - 1 ? 12 : 0),
|
||||
child: Opacity(
|
||||
opacity: itemOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
/// 멤버 화면 - 카드 스와이프 스타일
|
||||
/// 멤버 화면 (MVCS의 View 레이어)
|
||||
///
|
||||
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/member.dart';
|
||||
import '../../services/members_service.dart';
|
||||
import '../../controllers/members_controller.dart';
|
||||
|
||||
class MembersView extends StatefulWidget {
|
||||
class MembersView extends ConsumerStatefulWidget {
|
||||
const MembersView({super.key});
|
||||
|
||||
@override
|
||||
State<MembersView> createState() => _MembersViewState();
|
||||
ConsumerState<MembersView> createState() => _MembersViewState();
|
||||
}
|
||||
|
||||
class _MembersViewState extends State<MembersView> with TickerProviderStateMixin {
|
||||
List<Member> _members = [];
|
||||
bool _isLoading = true;
|
||||
int _currentIndex = 0;
|
||||
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
|
||||
late PageController _pageController;
|
||||
late ScrollController _indicatorScrollController;
|
||||
late AnimationController _animController;
|
||||
|
|
@ -37,6 +37,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
|
||||
// 이전 경로 저장 (탭 전환 감지용)
|
||||
String? _previousPath;
|
||||
bool _animationStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -48,7 +49,6 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_setupAnimations();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
/// 애니메이션 설정
|
||||
|
|
@ -123,46 +123,6 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
try {
|
||||
final members = await getMembers();
|
||||
// 현재 멤버 먼저, 전 멤버 나중에 정렬
|
||||
members.sort((a, b) {
|
||||
if (a.isFormer != b.isFormer) {
|
||||
return a.isFormer ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_members = members;
|
||||
_isLoading = false;
|
||||
});
|
||||
_animController.forward();
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 나이 계산
|
||||
int? _calculateAge(String? birthDate) {
|
||||
if (birthDate == null) return null;
|
||||
final birth = DateTime.tryParse(birthDate);
|
||||
if (birth == null) return null;
|
||||
final today = DateTime.now();
|
||||
int age = today.year - birth.year;
|
||||
if (today.month < birth.month ||
|
||||
(today.month == birth.month && today.day < birth.day)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
/// 생일 포맷팅
|
||||
String _formatBirthDate(String? birthDate) {
|
||||
if (birthDate == null) return '';
|
||||
return birthDate.substring(0, 10).replaceAll('-', '.');
|
||||
}
|
||||
|
||||
/// 인스타그램 열기 (딥링크 우선, 없으면 웹)
|
||||
Future<void> _openInstagram(String? url) async {
|
||||
|
|
@ -193,13 +153,24 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
final membersState = ref.watch(membersProvider);
|
||||
final controller = ref.read(membersProvider.notifier);
|
||||
|
||||
// 데이터 로드 완료 시 애니메이션 시작
|
||||
if (!membersState.isLoading && !_animationStarted && membersState.members.isNotEmpty) {
|
||||
_animationStarted = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_animController.forward();
|
||||
});
|
||||
}
|
||||
|
||||
if (membersState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
}
|
||||
|
||||
if (_members.isEmpty) {
|
||||
if (membersState.members.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
|
||||
);
|
||||
|
|
@ -215,7 +186,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
offset: Offset(0, _indicatorSlide.value),
|
||||
child: Opacity(
|
||||
opacity: _indicatorOpacity.value,
|
||||
child: _buildThumbnailIndicator(),
|
||||
child: _buildThumbnailIndicator(membersState),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -227,10 +198,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
opacity: _cardOpacity.value,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _members.length,
|
||||
itemCount: membersState.members.length,
|
||||
padEnds: true,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentIndex = index);
|
||||
controller.setCurrentIndex(index);
|
||||
HapticFeedback.selectionClick();
|
||||
_scrollIndicatorToIndex(index);
|
||||
},
|
||||
|
|
@ -245,7 +216,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
return Transform.scale(
|
||||
scale: Curves.easeOut.transform(value),
|
||||
child: _buildMemberCard(_members[index], index),
|
||||
child: _buildMemberCard(membersState.members[index], controller),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -261,9 +232,9 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
|
||||
/// 멤버 카드
|
||||
Widget _buildMemberCard(Member member, int index) {
|
||||
Widget _buildMemberCard(Member member, MembersController controller) {
|
||||
final isFormer = member.isFormer;
|
||||
final age = _calculateAge(member.birthDate);
|
||||
final age = controller.calculateAge(member.birthDate);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
|
|
@ -292,7 +263,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
child: CachedNetworkImage(
|
||||
imageUrl: member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
|
|
@ -401,7 +372,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
_buildIcon('calendar', 16, Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatBirthDate(member.birthDate),
|
||||
controller.formatBirthDate(member.birthDate),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
|
|
@ -477,7 +448,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
|
||||
/// 상단 썸네일 인디케이터
|
||||
Widget _buildThumbnailIndicator() {
|
||||
Widget _buildThumbnailIndicator(MembersState membersState) {
|
||||
return Container(
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -494,10 +465,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
controller: _indicatorScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
itemCount: _members.length,
|
||||
itemCount: membersState.members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = _members[index];
|
||||
final isSelected = index == _currentIndex;
|
||||
final member = membersState.members[index];
|
||||
final isSelected = index == membersState.currentIndex;
|
||||
final isFormer = member.isFormer;
|
||||
|
||||
return GestureDetector(
|
||||
|
|
@ -547,7 +518,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
fit: BoxFit.cover,
|
||||
width: 48,
|
||||
height: 48,
|
||||
placeholder: (_, _) => Container(color: Colors.grey[300]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[300]),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue