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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../models/album.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});
|
const AlbumView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AlbumView> createState() => _AlbumViewState();
|
ConsumerState<AlbumView> createState() => _AlbumViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumViewState extends State<AlbumView> {
|
class _AlbumViewState extends ConsumerState<AlbumView> {
|
||||||
late Future<List<Album>> _albumsFuture;
|
|
||||||
bool _initialLoadComplete = false;
|
bool _initialLoadComplete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_albumsFuture = getAlbums();
|
|
||||||
// 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함
|
// 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함
|
||||||
Future.delayed(const Duration(milliseconds: 600), () {
|
Future.delayed(const Duration(milliseconds: 600), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -34,10 +35,9 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<List<Album>>(
|
final albumState = ref.watch(albumProvider);
|
||||||
future: _albumsFuture,
|
|
||||||
builder: (context, snapshot) {
|
if (albumState.isLoading) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
|
|
@ -45,7 +45,7 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (albumState.error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|
@ -66,9 +66,7 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
ref.read(albumProvider.notifier).refresh();
|
||||||
_albumsFuture = getAlbums();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: const Text('다시 시도'),
|
child: const Text('다시 시도'),
|
||||||
),
|
),
|
||||||
|
|
@ -77,8 +75,6 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final albums = snapshot.data ?? [];
|
|
||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
|
|
@ -88,9 +84,9 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
),
|
),
|
||||||
itemCount: albums.length,
|
itemCount: albumState.albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = albums[index];
|
final album = albumState.albums[index];
|
||||||
return _AlbumCard(
|
return _AlbumCard(
|
||||||
album: album,
|
album: album,
|
||||||
index: index,
|
index: index,
|
||||||
|
|
@ -98,8 +94,6 @@ class _AlbumViewState extends State<AlbumView> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,25 @@
|
||||||
/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션
|
/// 홈 화면 (MVCS의 View 레이어)
|
||||||
|
///
|
||||||
|
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../models/member.dart';
|
|
||||||
import '../../models/album.dart';
|
|
||||||
import '../../models/schedule.dart';
|
import '../../models/schedule.dart';
|
||||||
import '../../services/members_service.dart';
|
import '../../controllers/home_controller.dart';
|
||||||
import '../../services/albums_service.dart';
|
|
||||||
import '../../services/schedules_service.dart';
|
|
||||||
|
|
||||||
class HomeView extends StatefulWidget {
|
class HomeView extends ConsumerStatefulWidget {
|
||||||
const HomeView({super.key});
|
const HomeView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HomeView> createState() => _HomeViewState();
|
ConsumerState<HomeView> createState() => _HomeViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMixin {
|
||||||
List<Member> _members = [];
|
|
||||||
List<Album> _albums = [];
|
|
||||||
List<Schedule> _schedules = [];
|
|
||||||
bool _isLoading = true;
|
|
||||||
bool _dataLoaded = false;
|
|
||||||
|
|
||||||
// 애니메이션 컨트롤러
|
// 애니메이션 컨트롤러
|
||||||
late AnimationController _animController;
|
late AnimationController _animController;
|
||||||
|
|
||||||
|
|
@ -43,12 +36,12 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
|
|
||||||
// 현재 경로 추적 (홈 탭 선택 시 애니메이션 재시작용)
|
// 현재 경로 추적 (홈 탭 선택 시 애니메이션 재시작용)
|
||||||
String? _previousPath;
|
String? _previousPath;
|
||||||
|
bool _animationStarted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupAnimations();
|
_setupAnimations();
|
||||||
_loadData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupAnimations() {
|
void _setupAnimations() {
|
||||||
|
|
@ -146,9 +139,10 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// go_router에서 현재 경로 감지
|
// go_router에서 현재 경로 감지
|
||||||
final currentPath = GoRouterState.of(context).uri.path;
|
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();
|
_startAnimations();
|
||||||
}
|
}
|
||||||
_previousPath = currentPath;
|
_previousPath = currentPath;
|
||||||
|
|
@ -160,36 +154,19 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return const Center(
|
||||||
child: CircularProgressIndicator(color: AppColors.primary),
|
child: CircularProgressIndicator(color: AppColors.primary),
|
||||||
);
|
);
|
||||||
|
|
@ -202,9 +179,9 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeroSection(),
|
_buildHeroSection(),
|
||||||
_buildMembersSection(),
|
_buildMembersSection(homeState),
|
||||||
_buildAlbumsSection(),
|
_buildAlbumsSection(homeState),
|
||||||
_buildSchedulesSection(),
|
_buildSchedulesSection(homeState),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -305,7 +282,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 멤버 섹션 - px-4(16px) py-6(24px)
|
/// 멤버 섹션 - px-4(16px) py-6(24px)
|
||||||
Widget _buildMembersSection() {
|
Widget _buildMembersSection(HomeState homeState) {
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: _membersSectionOpacity.value,
|
opacity: _membersSectionOpacity.value,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
|
|
@ -319,7 +296,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// grid-cols-5 gap-2(8px)
|
// grid-cols-5 gap-2(8px)
|
||||||
Row(
|
Row(
|
||||||
children: _members.asMap().entries.map((entry) {
|
children: homeState.members.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final member = entry.value;
|
final member = entry.value;
|
||||||
// 멤버 아이템: delay 0.4+index*0.05s, duration 0.3s
|
// 멤버 아이템: delay 0.4+index*0.05s, duration 0.3s
|
||||||
|
|
@ -361,7 +338,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: member.imageUrl!,
|
imageUrl: member.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|
@ -389,7 +366,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 앨범 섹션 - px-4(16px) py-6(24px)
|
/// 앨범 섹션 - px-4(16px) py-6(24px)
|
||||||
Widget _buildAlbumsSection() {
|
Widget _buildAlbumsSection(HomeState homeState) {
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: _albumsSectionOpacity.value,
|
opacity: _albumsSectionOpacity.value,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
|
|
@ -403,7 +380,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// grid-cols-2 gap-3(12px)
|
// grid-cols-2 gap-3(12px)
|
||||||
Row(
|
Row(
|
||||||
children: _albums.asMap().entries.map((entry) {
|
children: homeState.albums.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final album = entry.value;
|
final album = entry.value;
|
||||||
// 앨범 아이템: delay 0.6+index*0.1s, duration 0.3s
|
// 앨범 아이템: delay 0.6+index*0.1s, duration 0.3s
|
||||||
|
|
@ -457,7 +434,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: album.coverThumbUrl!,
|
imageUrl: album.coverThumbUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||||
)
|
)
|
||||||
: 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)
|
/// 일정 섹션 - px-4(16px) py-4(16px)
|
||||||
Widget _buildSchedulesSection() {
|
Widget _buildSchedulesSection(HomeState homeState) {
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: _schedulesSectionOpacity.value,
|
opacity: _schedulesSectionOpacity.value,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
|
|
@ -512,7 +489,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
// mb-4(16px)
|
// mb-4(16px)
|
||||||
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
|
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_schedules.isEmpty)
|
if (homeState.schedules.isEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -523,7 +500,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||||
else
|
else
|
||||||
// space-y-3(12px)
|
// space-y-3(12px)
|
||||||
Column(
|
Column(
|
||||||
children: _schedules.asMap().entries.map((entry) {
|
children: homeState.schedules.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final schedule = entry.value;
|
final schedule = entry.value;
|
||||||
// 일정 아이템: delay 0.8+index*0.1s, duration 0.3s, x -20→0
|
// 일정 아이템: 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(
|
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(
|
child: Opacity(
|
||||||
opacity: itemOpacity.value,
|
opacity: itemOpacity.value,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
/// 멤버 화면 - 카드 스와이프 스타일
|
/// 멤버 화면 (MVCS의 View 레이어)
|
||||||
|
///
|
||||||
|
/// UI 렌더링과 애니메이션만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/constants.dart';
|
import '../../core/constants.dart';
|
||||||
import '../../models/member.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});
|
const MembersView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MembersView> createState() => _MembersViewState();
|
ConsumerState<MembersView> createState() => _MembersViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MembersViewState extends State<MembersView> with TickerProviderStateMixin {
|
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
|
||||||
List<Member> _members = [];
|
|
||||||
bool _isLoading = true;
|
|
||||||
int _currentIndex = 0;
|
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
late ScrollController _indicatorScrollController;
|
late ScrollController _indicatorScrollController;
|
||||||
late AnimationController _animController;
|
late AnimationController _animController;
|
||||||
|
|
@ -37,6 +37,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
|
|
||||||
// 이전 경로 저장 (탭 전환 감지용)
|
// 이전 경로 저장 (탭 전환 감지용)
|
||||||
String? _previousPath;
|
String? _previousPath;
|
||||||
|
bool _animationStarted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -48,7 +49,6 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
duration: const Duration(milliseconds: 800),
|
duration: const Duration(milliseconds: 800),
|
||||||
);
|
);
|
||||||
_setupAnimations();
|
_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 {
|
Future<void> _openInstagram(String? url) async {
|
||||||
|
|
@ -193,13 +153,24 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return const Center(
|
||||||
child: CircularProgressIndicator(color: AppColors.primary),
|
child: CircularProgressIndicator(color: AppColors.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_members.isEmpty) {
|
if (membersState.members.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
|
child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
|
||||||
);
|
);
|
||||||
|
|
@ -215,7 +186,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
offset: Offset(0, _indicatorSlide.value),
|
offset: Offset(0, _indicatorSlide.value),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: _indicatorOpacity.value,
|
opacity: _indicatorOpacity.value,
|
||||||
child: _buildThumbnailIndicator(),
|
child: _buildThumbnailIndicator(membersState),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -227,10 +198,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
opacity: _cardOpacity.value,
|
opacity: _cardOpacity.value,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
itemCount: _members.length,
|
itemCount: membersState.members.length,
|
||||||
padEnds: true,
|
padEnds: true,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
setState(() => _currentIndex = index);
|
controller.setCurrentIndex(index);
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
_scrollIndicatorToIndex(index);
|
_scrollIndicatorToIndex(index);
|
||||||
},
|
},
|
||||||
|
|
@ -245,7 +216,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
}
|
}
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
scale: Curves.easeOut.transform(value),
|
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 isFormer = member.isFormer;
|
||||||
final age = _calculateAge(member.birthDate);
|
final age = controller.calculateAge(member.birthDate);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
|
@ -292,7 +263,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: member.imageUrl!,
|
imageUrl: member.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, _) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: CircularProgressIndicator(color: AppColors.primary),
|
child: CircularProgressIndicator(color: AppColors.primary),
|
||||||
|
|
@ -401,7 +372,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
_buildIcon('calendar', 16, Colors.white70),
|
_buildIcon('calendar', 16, Colors.white70),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
_formatBirthDate(member.birthDate),
|
controller.formatBirthDate(member.birthDate),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
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(
|
return Container(
|
||||||
height: 88,
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -494,10 +465,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
controller: _indicatorScrollController,
|
controller: _indicatorScrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
itemCount: _members.length,
|
itemCount: membersState.members.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = _members[index];
|
final member = membersState.members[index];
|
||||||
final isSelected = index == _currentIndex;
|
final isSelected = index == membersState.currentIndex;
|
||||||
final isFormer = member.isFormer;
|
final isFormer = member.isFormer;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|
@ -547,7 +518,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
placeholder: (_, _) => Container(color: Colors.grey[300]),
|
placeholder: (context, url) => Container(color: Colors.grey[300]),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue