guricode

[자취의 정석] Flutter 앱에 "전체" 카테고리 탭 추가하기: Clean Architecture 기반 구현 본문

앱/Flutter&Dart

[자취의 정석] Flutter 앱에 "전체" 카테고리 탭 추가하기: Clean Architecture 기반 구현

agentrakugaki 2025. 10. 12. 20:14

Flutter 앱의 커뮤니티 화면에 "전체" 카테고리 탭을 추가하는 과정을 Clean Architecture 패턴을 기반으로 구현해보겠습니다. 이 기능을 통해 사용자는 자신의 지역에 있는 모든 카테고리의 게시글을 한 번에 볼 수 있게 됩니다.

 

자취의 정석 커뮤니티는 탭이 2depth로 되어있다

 

메인 카테고리가 있고 서브카테고리가 있어서 이용자가 탭을 선택해서 볼수 있다

그런데 디자이너에게 전체탭을 추가해 달라는 요청이 있었다.

 

그래서 로직을 몇가지 수정하고 전체탭을 추가해야했다.

개선 클린 아키텍처 구조

lib/
├── domain/
│   ├── entities/community.dart
│   ├── repositories/community_repository.dart
│   └── usecases/fetch_communities.dart
├── data/
│   ├── datasources/community_data_source.dart
│   ├── datasources/community_data_source_impl.dart
│   └── repositories/community_repository_impl.dart
└── presentation/
    ├── screens/community/community_screen.dart
    └── screens/community/vm/community_list_vm.dart

 

 

 

1단계: Domain Layer 수정

 

 

기존에 만들어진 코드 중 몇가지를 수정했다

원래는 카테고리코드가 nullable이 아니었다.

하지만 이제 전체를 조회해야하기때문에 사용가능하게 바꿔야했다.

따라서 null값이 가능하도록 교체해줬다.

UseCase 

// lib/domain/usecases/fetch_communities.dart
class FetchCommunities {
  final CommunityRepository repo;
  FetchCommunities(this.repo);

  Future<(List<Community> items, DocumentSnapshot? lastDoc, bool hasMore)>
  call({
    int? categoryCode,        // nullable로 변경
    int? categoryDetailCode,  // nullable로 변경
    required String location,
    int limit = 10,
    DocumentSnapshot? startAfter,
    bool desc = true,
  }) async {
    final page = await repo.fetch(
      categoryCode: categoryCode,
      categoryDetailCode: categoryDetailCode,
      location: location,
      limit: limit,
      startAfter: startAfter,
      desc: desc,
    );
    return (page.items, page.lastDoc, page.hasMore);
  }
}

 

Repository Interface 수정

// lib/domain/repositories/community_repository.dart
abstract interface class CommunityRepository {
  Future<PagedCommunity> fetch({
    int? categoryCode,        // nullable로 변경
    int? categoryDetailCode,  // nullable로 변경
    required String location,
    int limit,
    DocumentSnapshot? startAfter,
    bool desc,
  });
}

2단계: Data Layer 수정

Data 레이어에는 조회쿼리가 들어가있기 때문에 이부분을 수정해줬다.

위치정보는 그대로 사용해야하기때문에 필수값이다.

DataSource Interface 수정

// lib/data/datasources/community_data_source.dart
abstract interface class CommunityDataSource {
  Future<PagedResult<CommunityDto>> fetchCommunities({
    int? categoryCode,        // nullable로 변경
    int? categoryDetailCode,  // nullable로 변경
    required String location,
    int limit,
    DocumentSnapshot? startAfterDoc,
    bool orderDesc,
  });
}

 

DataSource Implementation 수정

// lib/data/datasources/community_data_source_impl.dart
@override
Future<PagedResult<CommunityDto>> fetchCommunities({
  int? categoryCode,        // nullable로 변경
  int? categoryDetailCode,  // nullable로 변경
  required String location,
  int limit = 10,
  DocumentSnapshot<Object?>? startAfterDoc,
  bool orderDesc = true,
}) async {
  Query<Map<String, dynamic>> q = col
      .where('community_delete_yn', isEqualTo: false);

  // 카테고리 필터링 (null이 아닐 때만)
  if (categoryCode != null) {
    q = q.where('category_code', isEqualTo: categoryCode);
  }
  if (categoryDetailCode != null) {
    q = q.where('category_detail_code', isEqualTo: categoryDetailCode);
  }

  // 위치 필터링
  if (location.isNotEmpty) {
    q = q.where('location', isEqualTo: location);
  }

  // 정렬
  q = q
      .orderBy('community_create_date', descending: orderDesc)
      .limit(limit);

  if (startAfterDoc != null) {
    q = q.startAfterDocument(startAfterDoc);
  }

  final snap = await q.get();
  final items = snap.docs
      .map((d) => CommunityDto.fromFirebase(d.id, d.data()))
      .toList();
  final lastDoc = snap.docs.isNotEmpty ? snap.docs.last : null;

  return PagedResult(
    items: items,
    lastDoc: lastDoc,
    hasMore: snap.docs.length == limit,
  );
}

 

3단계: Presentation Layer 수정

 

ViewModel 수정

// lib/presentation/screens/community/vm/community_list_vm.dart
class CommunityListVM extends Notifier<CommunityListState> {
  CommunityListVM(this.categoryCode, this.detailCode, this.location);

  final int? categoryCode;    // nullable로 변경
  final int? detailCode;      // nullable로 변경
  final String location;

  // ... 기존 메서드들 유지
}

/// provider 팩토리 수정
NotifierProvider<CommunityListVM, CommunityListState> communityListVmProvider({
  int? categoryCode,    // nullable로 변경
  int? detailCode,      // nullable로 변경
  required String location,
}) {
  return NotifierProvider<CommunityListVM, CommunityListState>(
    () => CommunityListVM(categoryCode, detailCode, location),
  );
}

UI 수정

// lib/presentation/screens/community/community_screen.dart
return DefaultTabController(
  length: parents.length + 1, // "전체" 탭 추가
  child: Scaffold(
    appBar: AppBar(
      // ... 기존 AppBar 설정
      bottom: TabBar(
        // ... 기존 TabBar 설정
        tabs: [
          const Tab(text: '전체'), // "전체" 탭 추가
          ...parents.map((p) => Tab(text: p.categoryName)).toList(),
        ],
      ),
    ),
    body: TabBarView(
      children: [
        // "전체" 탭 뷰
        hasLocation
            ? _AllPostsView(location: location)
            : const NoLocationView(),
        // 기존 카테고리 탭 뷰들
        ...parents.map((p) {
          return hasLocation
              ? _SecondDepthTabs(
                  parentCode: p.categoryCode,
                  location: location,
                )
              : const NoLocationView();
        }).toList(),
      ],
    ),
  ),
);

"전체" 탭 전용 위젯 추가

// "전체" 탭 뷰 - 모든 카테고리의 게시글을 표시
class _AllPostsView extends ConsumerStatefulWidget {
  const _AllPostsView({required this.location});
  final String? location;

  @override
  ConsumerState<_AllPostsView> createState() => _AllPostsViewState();
}

class _AllPostsViewState extends ConsumerState<_AllPostsView> {
  late NotifierProvider<CommunityListVM, CommunityListState> provider;
  bool _ready = false;
  ProviderSubscription<int>? _changedSub;

  @override
  void initState() {
    super.initState();
    _maybeInitProviderAndLoad();
    _changedSub = ref.listenManual<int>(
      communityChangedTickProvider,
      (prev, next) {
        if (!_ready || !mounted) return;
        ref.invalidate(provider);
        Future.microtask(() => ref.read(provider.notifier).loadInitial(ref));
      },
    );
  }

  void _maybeInitProviderAndLoad() {
    final loc = widget.location;
    if (loc == null || loc.isEmpty) return;

    provider = communityListVmProvider(
      categoryCode: null,    // 모든 카테고리
      detailCode: null,      // 모든 하위 카테고리
      location: loc,
    );
    _ready = true;

    Future.microtask(() {
      if (!mounted) return;
      ref.read(provider.notifier).loadInitial(ref);
    });
  }

  @override
  Widget build(BuildContext context) {
    if (!_ready) return const NoLocationView();

    final st = ref.watch(provider);

    // ... 게시글 리스트 UI 구현
  }
}

 

 

 참고 자료