Flutter 앱: 전체 화면 MVCS 아키텍처 리팩토링

홈, 멤버, 앨범 화면을 Riverpod 기반 MVCS 패턴으로 리팩토링.
View는 UI와 애니메이션만, Controller는 상태와 비즈니스 로직 담당.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 18:18:44 +09:00
parent bc37abe473
commit 671618442c
6 changed files with 408 additions and 189 deletions

View 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,
);

View 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,
);

View 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,
);

View file

@ -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> {
); );
}, },
); );
},
);
} }
} }

View file

@ -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 -200 // : delay 0.8+index*0.1s, duration 0.3s, x -200
@ -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(

View file

@ -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,