refactor: Repository 패턴 적용 및 Clean Architecture 완성
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

## 주요 변경사항

### 🏗️ Architecture
- Repository 패턴 전면 도입 (인터페이스/구현체 분리)
- Domain Layer에 Repository 인터페이스 정의
- Data Layer에 Repository 구현체 배치
- UseCase 의존성을 Service에서 Repository로 전환

### 📦 Dependency Injection
- GetIt 기반 DI Container 재구성 (lib/injection_container.dart)
- Repository 인터페이스와 구현체 등록
- Service와 Repository 공존 (마이그레이션 기간)

### 🔄 Migration Status
완료:
- License 모듈 (6개 UseCase)
- Warehouse Location 모듈 (5개 UseCase)

진행중:
- Auth 모듈 (2/5 UseCase)
- Company 모듈 (1/6 UseCase)

대기:
- User 모듈 (7개 UseCase)
- Equipment 모듈 (4개 UseCase)

### 🎯 Controller 통합
- 중복 Controller 제거 (with_usecase 버전)
- 단일 Controller로 통합
- UseCase 패턴 직접 적용

### 🧹 코드 정리
- 임시 파일 제거 (test_*.md, task.md)
- Node.js 아티팩트 제거 (package.json)
- 불필요한 테스트 파일 정리

###  테스트 개선
- Real API 중심 테스트 구조
- Mock 제거, 실제 API 엔드포인트 사용
- 통합 테스트 프레임워크 강화

## 기술적 영향
- 의존성 역전 원칙 적용
- 레이어 간 결합도 감소
- 테스트 용이성 향상
- 확장성 및 유지보수성 개선

## 다음 단계
1. User/Equipment 모듈 Repository 마이그레이션
2. Service Layer 점진적 제거
3. 캐싱 전략 구현
4. 성능 최적화
This commit is contained in:
JiWoong Sul
2025-08-11 20:14:10 +09:00
parent d64aa26157
commit 731dcd816b
105 changed files with 5225 additions and 3941 deletions

View File

@@ -38,7 +38,7 @@ class _AppLayoutState extends State<AppLayout>
late final DashboardService _dashboardService;
late final LookupService _lookupService;
late Animation<double> _sidebarAnimation;
int _expiringLicenseCount = 0; // 30일 내 만료 예정 라이선스 수
int _expiringLicenseCount = 0; // 7일 내 만료 예정 라이선스 수
// 레이아웃 상수 (1920x1080 최적화)
static const double _sidebarExpandedWidth = 260.0;
@@ -79,6 +79,7 @@ class _AppLayoutState extends State<AppLayout>
},
(summary) {
print('[DEBUG] 라이선스 만료 정보 로드 성공!');
print('[DEBUG] 7일 내 만료: ${summary.expiring7Days ?? 0}');
print('[DEBUG] 30일 내 만료: ${summary.within30Days}');
print('[DEBUG] 60일 내 만료: ${summary.within60Days}');
print('[DEBUG] 90일 내 만료: ${summary.within90Days}');
@@ -86,8 +87,10 @@ class _AppLayoutState extends State<AppLayout>
if (mounted) {
setState(() {
// 30일 내 만료 수를 표시 (7일 내 만료가 포함됨)
// expiring_30_days는 30일 이내의 모든 라이선스를 포함
_expiringLicenseCount = summary.within30Days;
print('[DEBUG] 상태 업데이트 완료: $_expiringLicenseCount');
print('[DEBUG] 상태 업데이트 완료: $_expiringLicenseCount (30일 내 만료)');
});
}
},

View File

@@ -33,7 +33,7 @@ class _CompanyListState extends State<CompanyList> {
void initState() {
super.initState();
_controller = CompanyListController();
_controller.initializeWithPageSize(10); // 페이지 크기 설정
_controller.initialize(pageSize: 10); // 통일된 초기화 방식
}
@override
@@ -430,18 +430,13 @@ class _CompanyListState extends State<CompanyList> {
],
),
// 페이지네이션 (Controller 상태 사용)
// 페이지네이션 (BaseListController의 goToPage 사용)
pagination: Pagination(
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
onPageChanged: (page) {
// 다음 페이지 로드
if (page > controller.currentPage) {
controller.loadNextPage();
} else if (page == 1) {
controller.refresh();
}
controller.goToPage(page);
},
),
);

View File

@@ -32,15 +32,9 @@ class CompanyListController extends BaseListController<Company> {
}
}
// 초기 데이터 로드
Future<void> initialize() async {
await loadData(isRefresh: true);
}
// 페이지 크기를 지정하여 초기화
// 기존 initializeWithPageSize를 사용하는 코드와의 호환성 유지
Future<void> initializeWithPageSize(int newPageSize) async {
pageSize = newPageSize;
await loadData(isRefresh: true);
await initialize(pageSize: newPageSize);
}
@override
@@ -48,8 +42,8 @@ class CompanyListController extends BaseListController<Company> {
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// API 호출 - 회사 목록 조회 (이제 PaginatedResponse 반환)
final response = await ErrorHandler.handleApiCall<dynamic>(
// API 호출 - 회사 목록 조회 (PaginatedResponse 반환)
final response = await ErrorHandler.handleApiCall(
() => _companyService.getCompanies(
page: params.page,
perPage: params.perPage,
@@ -61,6 +55,20 @@ class CompanyListController extends BaseListController<Company> {
},
);
if (response == null) {
return PagedResult(
items: [],
meta: PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: 0,
totalPages: 0,
hasNext: false,
hasPrevious: false,
),
);
}
// PaginatedResponse를 PagedResult로 변환
final meta = PaginationMeta(
currentPage: response.page,

View File

@@ -1,294 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/errors/failures.dart';
import '../../../domain/usecases/base_usecase.dart';
import '../../../domain/usecases/company/company_usecases.dart';
import '../../../models/company_model.dart';
import '../../../services/company_service.dart';
import '../../../di/injection_container.dart';
import '../../../data/models/common/pagination_params.dart';
/// UseCase를 활용한 회사 목록 관리 컨트롤러
/// BaseListController를 상속받아 공통 기능 재사용
class CompanyListControllerWithUseCase extends BaseListController<Company> {
// UseCases
late final GetCompaniesUseCase _getCompaniesUseCase;
late final CreateCompanyUseCase _createCompanyUseCase;
late final UpdateCompanyUseCase _updateCompanyUseCase;
late final DeleteCompanyUseCase _deleteCompanyUseCase;
late final GetCompanyDetailUseCase _getCompanyDetailUseCase;
late final ToggleCompanyStatusUseCase _toggleCompanyStatusUseCase;
// 필터 상태
String? selectedType;
bool? isActive;
// 선택된 회사들
final Set<int> _selectedCompanyIds = {};
Set<int> get selectedCompanyIds => _selectedCompanyIds;
bool get hasSelection => _selectedCompanyIds.isNotEmpty;
CompanyListControllerWithUseCase() {
// UseCase 초기화
final companyService = inject<CompanyService>();
_getCompaniesUseCase = GetCompaniesUseCase(companyService);
_createCompanyUseCase = CreateCompanyUseCase(companyService);
_updateCompanyUseCase = UpdateCompanyUseCase(companyService);
_deleteCompanyUseCase = DeleteCompanyUseCase(companyService);
_getCompanyDetailUseCase = GetCompanyDetailUseCase(companyService);
_toggleCompanyStatusUseCase = ToggleCompanyStatusUseCase(companyService);
}
@override
Future<PagedResult<Company>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
// UseCase를 통한 데이터 조회
final usecaseParams = GetCompaniesParams(
page: params.page,
perPage: params.perPage,
search: params.search,
isActive: isActive,
);
final result = await _getCompaniesUseCase(usecaseParams);
return result.fold(
(failure) {
throw Exception(failure.message);
},
(companies) {
// PagedResult로 래핑하여 반환 (임시로 메타데이터 생성)
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: companies.length, // 실제로는 서버에서 받아와야 함
totalPages: (companies.length / params.perPage).ceil(),
hasNext: companies.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: companies, meta: meta);
},
);
}
/// 회사 생성
Future<bool> createCompany(Company company) async {
isLoadingState = true;
notifyListeners();
final params = CreateCompanyParams(company: company);
final result = await _createCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
// ValidationFailure의 경우 상세 에러 표시
if (failure is ValidationFailure && failure.errors != null) {
final errorMessages = failure.errors!.entries
.map((e) => '${e.key}: ${e.value}')
.join('\n');
errorState = errorMessages;
}
isLoadingState = false;
notifyListeners();
return false;
},
(newCompany) {
// 로컬 리스트에 추가
addItemLocally(newCompany);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 수정
Future<bool> updateCompany(int id, Company company) async {
isLoadingState = true;
notifyListeners();
final params = UpdateCompanyParams(id: id, company: company);
final result = await _updateCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
// ValidationFailure의 경우 상세 에러 표시
if (failure is ValidationFailure && failure.errors != null) {
final errorMessages = failure.errors!.entries
.map((e) => '${e.key}: ${e.value}')
.join('\n');
errorState = errorMessages;
}
isLoadingState = false;
notifyListeners();
return false;
},
(updatedCompany) {
// 로컬 리스트 업데이트
updateItemLocally(updatedCompany, (item) => item.id == id);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 삭제
Future<bool> deleteCompany(int id) async {
isLoadingState = true;
notifyListeners();
final params = DeleteCompanyParams(id: id);
final result = await _deleteCompanyUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
isLoadingState = false;
notifyListeners();
return false;
},
(_) {
// 로컬 리스트에서 제거
removeItemLocally((item) => item.id == id);
_selectedCompanyIds.remove(id);
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 상세 조회
Future<Company?> getCompanyDetail(int id, {bool includeBranches = false}) async {
final params = GetCompanyDetailParams(
id: id,
includeBranches: includeBranches,
);
final result = await _getCompanyDetailUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
notifyListeners();
return null;
},
(company) => company,
);
}
/// 회사 상태 토글 (활성화/비활성화)
Future<bool> toggleCompanyStatus(int id) async {
isLoadingState = true;
notifyListeners();
// 현재 회사 상태를 확인하여 토글 (기본값 true로 가정)
final params = ToggleCompanyStatusParams(
id: id,
isActive: false, // 임시로 false로 설정 (실제로는 현재 상태를 API로 확인해야 함)
);
final result = await _toggleCompanyStatusUseCase(params);
return result.fold(
(failure) {
errorState = failure.message;
isLoadingState = false;
notifyListeners();
return false;
},
(_) {
// 로컬 리스트에서 상태 업데이트 (실제로는 API에서 업데이트된 Company 객체를 받아와야 함)
isLoadingState = false;
notifyListeners();
return true;
},
);
}
/// 회사 선택/해제
void toggleSelection(int companyId) {
if (_selectedCompanyIds.contains(companyId)) {
_selectedCompanyIds.remove(companyId);
} else {
_selectedCompanyIds.add(companyId);
}
notifyListeners();
}
/// 전체 선택/해제
void toggleSelectAll() {
if (_selectedCompanyIds.length == items.length) {
_selectedCompanyIds.clear();
} else {
_selectedCompanyIds.clear();
_selectedCompanyIds.addAll(items.where((c) => c.id != null).map((c) => c.id!));
}
notifyListeners();
}
/// 선택 초기화
void clearSelection() {
_selectedCompanyIds.clear();
notifyListeners();
}
/// 필터 적용
void applyFilters({String? type, bool? active}) {
selectedType = type;
isActive = active;
refresh();
}
/// 필터 초기화
void clearFilters() {
selectedType = null;
isActive = null;
refresh();
}
/// 선택된 회사들 일괄 삭제
Future<bool> deleteSelectedCompanies() async {
if (_selectedCompanyIds.isEmpty) return false;
isLoadingState = true;
notifyListeners();
bool allSuccess = true;
final failedIds = <int>[];
for (final id in _selectedCompanyIds.toList()) {
final params = DeleteCompanyParams(id: id);
final result = await _deleteCompanyUseCase(params);
result.fold(
(failure) {
allSuccess = false;
failedIds.add(id);
debugPrint('회사 $id 삭제 실패: ${failure.message}');
},
(_) {
removeItemLocally((item) => item.id == id);
},
);
}
if (failedIds.isNotEmpty) {
errorState = '일부 회사 삭제 실패: ${failedIds.join(', ')}';
}
_selectedCompanyIds.clear();
isLoadingState = false;
notifyListeners();
return allSuccess;
}
}

View File

@@ -103,16 +103,14 @@ class EquipmentListController extends BaseListController<UnifiedEquipment> {
);
}).toList();
// 임시로 메타데이터 생성 (추후 API에서 실제 메타데이터 반환 시 수정)
// API에서 반환한 실제 메타데이터 사용
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: items.length < params.perPage ?
(params.page - 1) * params.perPage + items.length :
(params.page * params.perPage) + 1,
totalPages: items.length < params.perPage ? params.page : params.page + 1,
hasNext: items.length >= params.perPage,
hasPrevious: params.page > 1,
currentPage: apiEquipmentDtos.page,
perPage: apiEquipmentDtos.size,
total: apiEquipmentDtos.totalElements,
totalPages: apiEquipmentDtos.totalPages,
hasNext: !apiEquipmentDtos.last,
hasPrevious: !apiEquipmentDtos.first,
);
return PagedResult(items: items, meta: meta);

View File

@@ -168,10 +168,11 @@ class _EquipmentListState extends State<EquipmentList> {
/// 필터링된 장비 목록 반환
List<UnifiedEquipment> _getFilteredEquipments() {
// 서버에서 이미 페이지네이션된 데이터를 사용
var equipments = _controller.equipments;
print('DEBUG: Total equipments from controller: ${equipments.length}'); // 디버그 정보
// 검색 키워드 적용 (확장된 검색 필드)
// 로컬 검색 키워드 적용 (서버 검색과 병행)
// 서버에서 검색된 결과에 추가 로컬 필터링
if (_appliedSearchKeyword.isNotEmpty) {
equipments = equipments.where((e) {
final keyword = _appliedSearchKeyword.toLowerCase();
@@ -190,8 +191,6 @@ class _EquipmentListState extends State<EquipmentList> {
}).toList();
}
print('DEBUG: Filtered equipments count: ${equipments.length}'); // 디버그 정보
print('DEBUG: Selected status filter: $_selectedStatus'); // 디버그 정보
return equipments;
}
@@ -392,7 +391,8 @@ class _EquipmentListState extends State<EquipmentList> {
final int selectedRentCount = controller.getSelectedEquipmentCountByStatus(EquipmentStatus.rent);
final filteredEquipments = _getFilteredEquipments();
final totalCount = filteredEquipments.length;
// 백엔드 API에서 제공하는 실제 전체 아이템 수 사용
final totalCount = controller.total;
return BaseListScreen(
isLoading: controller.isLoading && controller.equipments.isEmpty,
@@ -414,8 +414,8 @@ class _EquipmentListState extends State<EquipmentList> {
dataTable: _buildDataTable(filteredEquipments),
// 페이지네이션
pagination: totalCount > controller.pageSize ? Pagination(
totalCount: totalCount,
pagination: controller.totalPages > 1 ? Pagination(
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
onPageChanged: (page) {
@@ -944,17 +944,12 @@ class _EquipmentListState extends State<EquipmentList> {
/// 데이터 테이블
Widget _buildDataTable(List<UnifiedEquipment> filteredEquipments) {
final int startIndex = (_controller.currentPage - 1) * _controller.pageSize;
final int endIndex =
(startIndex + _controller.pageSize) > filteredEquipments.length
? filteredEquipments.length
: (startIndex + _controller.pageSize);
final List<UnifiedEquipment> pagedEquipments = filteredEquipments.sublist(
startIndex,
endIndex,
);
// 백엔드에서 이미 페이지네이션된 데이터를 받으므로
// 프론트엔드에서 추가 페이징 불필요
final List<UnifiedEquipment> pagedEquipments = filteredEquipments;
if (pagedEquipments.isEmpty) {
// 전체 데이터가 없는지 확인 (API의 total 사용)
if (_controller.total == 0 && pagedEquipments.isEmpty) {
return StandardEmptyState(
title:
_appliedSearchKeyword.isNotEmpty
@@ -1173,19 +1168,9 @@ class _EquipmentListState extends State<EquipmentList> {
/// 페이지 데이터 가져오기
List<UnifiedEquipment> _getPagedEquipments() {
final filteredEquipments = _getFilteredEquipments();
final int startIndex = (_controller.currentPage - 1) * _controller.pageSize;
final int endIndex = startIndex + _controller.pageSize;
if (startIndex >= filteredEquipments.length) {
return [];
}
final actualEndIndex = endIndex > filteredEquipments.length
? filteredEquipments.length
: endIndex;
return filteredEquipments.sublist(startIndex, actualEndIndex);
// 서버 페이지네이션 사용: 컨트롤러의 items가 이미 페이지네이션된 데이터
// 로컬 필터링만 적용
return _getFilteredEquipments();
}
/// 카테고리 축약 표기 함수

View File

@@ -1,283 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/utils/error_handler.dart';
import '../../../data/models/common/pagination_params.dart';
import '../../../data/models/license/license_dto.dart';
import '../../../domain/usecases/license/license_usecases.dart';
/// UseCase 패턴을 적용한 라이선스 목록 컨트롤러
class LicenseListControllerWithUseCase extends BaseListController<LicenseDto> {
final GetLicensesUseCase getLicensesUseCase;
final CreateLicenseUseCase createLicenseUseCase;
final UpdateLicenseUseCase updateLicenseUseCase;
final DeleteLicenseUseCase deleteLicenseUseCase;
final CheckLicenseExpiryUseCase checkLicenseExpiryUseCase;
// 선택된 항목들
final Set<int> _selectedLicenseIds = {};
Set<int> get selectedLicenseIds => _selectedLicenseIds;
// 필터 옵션
String? _filterByCompany;
String? _filterByExpiry;
DateTime? _filterStartDate;
DateTime? _filterEndDate;
String? get filterByCompany => _filterByCompany;
String? get filterByExpiry => _filterByExpiry;
DateTime? get filterStartDate => _filterStartDate;
DateTime? get filterEndDate => _filterEndDate;
// 만료 임박 라이선스 정보
LicenseExpiryResult? _expiryResult;
LicenseExpiryResult? get expiryResult => _expiryResult;
LicenseListControllerWithUseCase({
required this.getLicensesUseCase,
required this.createLicenseUseCase,
required this.updateLicenseUseCase,
required this.deleteLicenseUseCase,
required this.checkLicenseExpiryUseCase,
});
@override
Future<PagedResult<LicenseDto>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
try {
// 필터 파라미터 구성
final filters = <String, dynamic>{};
if (_filterByCompany != null) filters['company_id'] = _filterByCompany;
if (_filterByExpiry != null) filters['expiry'] = _filterByExpiry;
if (_filterStartDate != null) filters['start_date'] = _filterStartDate!.toIso8601String();
if (_filterEndDate != null) filters['end_date'] = _filterEndDate!.toIso8601String();
final updatedParams = params.copyWith(filters: filters);
final getParams = GetLicensesParams.fromPaginationParams(updatedParams);
final result = await getLicensesUseCase(getParams);
return result.fold(
(failure) => throw Exception(failure.message),
(licenseResponse) {
// PagedResult로 래핑하여 반환
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: licenseResponse.items.length, // 실제로는 서버에서 받아와야 함
totalPages: (licenseResponse.items.length / params.perPage).ceil(),
hasNext: licenseResponse.items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: licenseResponse.items, meta: meta);
},
);
} catch (e) {
throw Exception('데이터 로드 실패: $e');
}
}
/// 만료 임박 라이선스 체크
Future<void> checkExpiringLicenses() async {
try {
final params = CheckLicenseExpiryParams(
companyId: _filterByCompany != null ? int.tryParse(_filterByCompany!) : null,
);
final result = await checkLicenseExpiryUseCase(params);
result.fold(
(failure) => errorState = failure.message,
(expiryResult) {
_expiryResult = expiryResult;
notifyListeners();
},
);
} catch (e) {
errorState = '라이선스 만료 체크 실패: $e';
}
}
/// 라이선스 생성
Future<void> createLicense({
required int equipmentId,
required int companyId,
required String licenseType,
required DateTime startDate,
required DateTime expiryDate,
String? description,
double? cost,
}) async {
try {
isLoadingState = true;
final params = CreateLicenseParams(
equipmentId: equipmentId,
companyId: companyId,
licenseType: licenseType,
startDate: startDate,
expiryDate: expiryDate,
description: description,
cost: cost,
);
final result = await createLicenseUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(license) async {
await refresh();
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 라이선스 수정
Future<void> updateLicense({
required int id,
int? equipmentId,
int? companyId,
String? licenseType,
DateTime? startDate,
DateTime? expiryDate,
String? description,
double? cost,
String? status,
}) async {
try {
isLoadingState = true;
final params = UpdateLicenseParams(
id: id,
equipmentId: equipmentId,
companyId: companyId,
licenseType: licenseType,
startDate: startDate,
expiryDate: expiryDate,
description: description,
cost: cost,
status: status,
);
final result = await updateLicenseUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(license) async {
updateItemLocally(license, (item) => item.id == license.id);
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 라이선스 삭제
Future<void> deleteLicense(int id) async {
try {
isLoadingState = true;
final result = await deleteLicenseUseCase(id);
await result.fold(
(failure) async => errorState = failure.message,
(_) async {
removeItemLocally((item) => item.id == id);
_selectedLicenseIds.remove(id);
await checkExpiringLicenses();
},
);
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
/// 필터 설정
void setFilters({
String? company,
String? expiry,
DateTime? startDate,
DateTime? endDate,
}) {
_filterByCompany = company;
_filterByExpiry = expiry;
_filterStartDate = startDate;
_filterEndDate = endDate;
refresh();
}
/// 필터 초기화
void clearFilters() {
_filterByCompany = null;
_filterByExpiry = null;
_filterStartDate = null;
_filterEndDate = null;
refresh();
}
/// 라이선스 선택 토글
void toggleLicenseSelection(int id) {
if (_selectedLicenseIds.contains(id)) {
_selectedLicenseIds.remove(id);
} else {
_selectedLicenseIds.add(id);
}
notifyListeners();
}
/// 모든 라이선스 선택
void selectAll() {
_selectedLicenseIds.clear();
_selectedLicenseIds.addAll(items.map((e) => e.id));
notifyListeners();
}
/// 선택 해제
void clearSelection() {
_selectedLicenseIds.clear();
notifyListeners();
}
/// 선택된 라이선스 일괄 삭제
Future<void> deleteSelectedLicenses() async {
if (_selectedLicenseIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLicenseIds.toList()) {
final result = await deleteLicenseUseCase(id);
result.fold(
(failure) => print('Failed to delete license $id: ${failure.message}'),
(_) => removeItemLocally((item) => item.id == id),
);
}
_selectedLicenseIds.clear();
await checkExpiringLicenses();
notifyListeners();
} catch (e) {
errorState = '오류 생성: $e';
} finally {
isLoadingState = false;
}
}
@override
void dispose() {
_selectedLicenseIds.clear();
_expiryResult = null;
super.dispose();
}
}

View File

@@ -44,6 +44,7 @@ class _LicenseListState extends State<LicenseList> {
// 실제 API 사용 여부에 따라 컨트롤러 초기화
final useApi = env.Environment.useApi;
_controller = LicenseListController();
_controller.pageSize = 10; // 페이지 크기를 10으로 설정
debugPrint('📌 Controller 모드: ${useApi ? "Real API" : "Mock Data"}');
debugPrint('==========================================\n');
@@ -239,16 +240,17 @@ class _LicenseListState extends State<LicenseList> {
child: Consumer<LicenseListController>(
builder: (context, controller, child) {
final licenses = controller.licenses;
final totalCount = licenses.length;
// 백엔드 API에서 제공하는 실제 전체 아이템 수 사용
final totalCount = controller.total;
return BaseListScreen(
headerSection: _buildStatisticsCards(),
searchBar: _buildSearchBar(),
actionBar: _buildActionBar(),
dataTable: _buildDataTable(),
pagination: totalCount > controller.pageSize
pagination: controller.total > 0
? Pagination(
totalCount: totalCount,
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
onPageChanged: (page) {
@@ -597,6 +599,7 @@ class _LicenseListState extends State<LicenseList> {
...pagedLicenses.asMap().entries.map((entry) {
final displayIndex = entry.key;
final license = entry.value;
// 백엔드에서 이미 페이지네이션된 데이터를 받으므로 추가 계산 불필요
final index = (_controller.currentPage - 1) * _controller.pageSize + displayIndex;
final daysRemaining = _controller.getDaysUntilExpiry(license.expiryDate);

View File

@@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
import 'package:dartz/dartz.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/data/models/auth/login_request.dart';
import 'package:superport/di/injection_container.dart';
import 'package:superport/injection_container.dart';
import 'package:superport/services/auth_service.dart';
import 'package:superport/services/health_test_service.dart';
import 'package:superport/services/health_check_service.dart';
/// 로그인 화면의 상태 및 비즈니스 로직을 담당하는 ChangeNotifier 기반 컨트롤러
class LoginController extends ChangeNotifier {
final AuthService _authService = inject<AuthService>();
final AuthService _authService = sl<AuthService>();
final HealthCheckService _healthCheckService = HealthCheckService();
/// 아이디 입력 컨트롤러
final TextEditingController idController = TextEditingController();

View File

@@ -1,193 +0,0 @@
import 'package:flutter/material.dart';
import 'package:dartz/dartz.dart';
import '../../../core/errors/failures.dart';
import '../../../domain/usecases/base_usecase.dart';
import '../../../domain/usecases/auth/login_usecase.dart';
import '../../../domain/usecases/auth/check_auth_status_usecase.dart';
import '../../../services/auth_service.dart';
import '../../../services/health_check_service.dart';
import '../../../di/injection_container.dart';
/// UseCase를 활용한 로그인 화면 컨트롤러
/// 비즈니스 로직을 UseCase로 분리하여 테스트 용이성과 재사용성 향상
class LoginControllerWithUseCase extends ChangeNotifier {
// UseCases
late final LoginUseCase _loginUseCase;
late final CheckAuthStatusUseCase _checkAuthStatusUseCase;
// Services
final HealthCheckService _healthCheckService = HealthCheckService();
// UI Controllers
final TextEditingController idController = TextEditingController();
final TextEditingController pwController = TextEditingController();
// Focus Nodes
final FocusNode idFocus = FocusNode();
final FocusNode pwFocus = FocusNode();
// State
bool saveId = false;
bool _isLoading = false;
String? _errorMessage;
// Getters
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
LoginControllerWithUseCase() {
// UseCase 초기화
final authService = inject<AuthService>();
_loginUseCase = LoginUseCase(authService);
_checkAuthStatusUseCase = CheckAuthStatusUseCase(authService);
// 초기 인증 상태 확인
_checkInitialAuthStatus();
}
/// 초기 인증 상태 확인
Future<void> _checkInitialAuthStatus() async {
final result = await _checkAuthStatusUseCase(const NoParams());
result.fold(
(failure) => debugPrint('인증 상태 확인 실패: ${failure.message}'),
(isAuthenticated) {
if (isAuthenticated) {
debugPrint('이미 로그인된 상태입니다.');
}
},
);
}
/// 아이디 저장 체크박스 상태 변경
void setSaveId(bool value) {
saveId = value;
notifyListeners();
}
/// 에러 메시지 초기화
void clearError() {
_errorMessage = null;
notifyListeners();
}
/// 로그인 처리
Future<bool> login() async {
// 입력값 검증 (UI 레벨)
if (idController.text.trim().isEmpty) {
_errorMessage = '아이디 또는 이메일을 입력해주세요.';
notifyListeners();
return false;
}
if (pwController.text.isEmpty) {
_errorMessage = '비밀번호를 입력해주세요.';
notifyListeners();
return false;
}
// 로딩 시작
_isLoading = true;
_errorMessage = null;
notifyListeners();
// 입력값이 이메일인지 username인지 판단
final inputValue = idController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
final isEmail = emailRegex.hasMatch(inputValue);
try {
// UseCase 실행
final params = LoginParams(
email: isEmail ? inputValue : '$inputValue@superport.kr', // username인 경우 임시 도메인 추가
password: pwController.text,
);
debugPrint('[LoginController] 로그인 시도: ${params.email}');
final result = await _loginUseCase(params).timeout(
const Duration(seconds: 10),
onTimeout: () async {
debugPrint('[LoginController] 로그인 요청 타임아웃');
return Left(NetworkFailure(
message: '요청 시간이 초과되었습니다. 네트워크 연결을 확인해주세요.',
));
},
);
return result.fold(
(failure) {
debugPrint('[LoginController] 로그인 실패: ${failure.message}');
// 실패 타입에 따른 메시지 처리
if (failure is ValidationFailure) {
_errorMessage = failure.message;
} else if (failure is AuthenticationFailure) {
_errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
} else if (failure is NetworkFailure) {
_errorMessage = '네트워크 연결을 확인해주세요.';
} else if (failure is ServerFailure) {
_errorMessage = '서버 오류가 발생했습니다.\n잠시 후 다시 시도해주세요.';
} else {
_errorMessage = failure.message;
}
_isLoading = false;
notifyListeners();
return false;
},
(loginResponse) {
debugPrint('[LoginController] 로그인 성공');
_isLoading = false;
notifyListeners();
return true;
},
);
} catch (e) {
debugPrint('[LoginController] 예상치 못한 에러: $e');
_errorMessage = '로그인 중 오류가 발생했습니다.';
_isLoading = false;
notifyListeners();
return false;
}
}
/// 헬스체크 실행
Future<bool> performHealthCheck() async {
debugPrint('[LoginController] 헬스체크 시작');
_isLoading = true;
notifyListeners();
try {
final healthResult = await _healthCheckService.checkHealth();
_isLoading = false;
notifyListeners();
// HealthCheckService가 Map을 반환하는 경우 적절히 변환
final isHealthy = healthResult is bool ? healthResult :
(healthResult is Map && healthResult['status'] == 'healthy');
if (isHealthy == false) {
_errorMessage = '서버와 연결할 수 없습니다.\n잠시 후 다시 시도해주세요.';
notifyListeners();
return false;
}
return true;
} catch (e) {
debugPrint('[LoginController] 헬스체크 실패: $e');
_errorMessage = '서버 상태 확인 중 오류가 발생했습니다.';
_isLoading = false;
notifyListeners();
return false;
}
}
@override
void dispose() {
idController.dispose();
pwController.dispose();
idFocus.dispose();
pwFocus.dispose();
super.dispose();
}
}

View File

@@ -28,8 +28,7 @@ class _UserListState extends State<UserList> {
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = context.read<UserListController>();
controller.pageSize = 10; // 페이지 크기 설정
controller.loadUsers();
controller.initialize(pageSize: 10); // 통일된 초기화 방식
});
// 검색 디바운싱

View File

@@ -1,300 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/controllers/base_list_controller.dart';
import '../../../core/utils/error_handler.dart';
import '../../../data/models/common/pagination_params.dart';
import '../../../data/models/warehouse/warehouse_dto.dart';
import '../../../domain/usecases/warehouse_location/warehouse_location_usecases.dart';
/// UseCase 패턴을 적용한 창고 위치 목록 컨트롤러
class WarehouseLocationListControllerWithUseCase extends BaseListController<WarehouseLocationDto> {
final GetWarehouseLocationsUseCase getWarehouseLocationsUseCase;
final CreateWarehouseLocationUseCase createWarehouseLocationUseCase;
final UpdateWarehouseLocationUseCase updateWarehouseLocationUseCase;
final DeleteWarehouseLocationUseCase deleteWarehouseLocationUseCase;
// 선택된 항목들
final Set<int> _selectedLocationIds = {};
Set<int> get selectedLocationIds => _selectedLocationIds;
// 필터 옵션
bool _showActiveOnly = true;
String? _filterByManager;
bool get showActiveOnly => _showActiveOnly;
String? get filterByManager => _filterByManager;
WarehouseLocationListControllerWithUseCase({
required this.getWarehouseLocationsUseCase,
required this.createWarehouseLocationUseCase,
required this.updateWarehouseLocationUseCase,
required this.deleteWarehouseLocationUseCase,
});
@override
Future<PagedResult<WarehouseLocationDto>> fetchData({
required PaginationParams params,
Map<String, dynamic>? additionalFilters,
}) async {
try {
// 필터 파라미터 구성
final filters = <String, dynamic>{};
if (_showActiveOnly) filters['is_active'] = true;
if (_filterByManager != null) filters['manager'] = _filterByManager;
final updatedParams = params.copyWith(filters: filters);
final getParams = GetWarehouseLocationsParams.fromPaginationParams(updatedParams);
final result = await getWarehouseLocationsUseCase(getParams);
return result.fold(
(failure) => throw Exception(failure.message),
(locationsResponse) {
// PagedResult로 래핑하여 반환
final meta = PaginationMeta(
currentPage: params.page,
perPage: params.perPage,
total: locationsResponse.items.length,
totalPages: (locationsResponse.items.length / params.perPage).ceil(),
hasNext: locationsResponse.items.length >= params.perPage,
hasPrevious: params.page > 1,
);
return PagedResult(items: locationsResponse.items, meta: meta);
},
);
} catch (e) {
throw Exception('데이터 로드 실패: $e');
}
}
/// 창고 위치 생성
Future<void> createWarehouseLocation({
required String name,
required String address,
String? description,
String? contactNumber,
String? manager,
double? latitude,
double? longitude,
}) async {
try {
isLoadingState = true;
final params = CreateWarehouseLocationParams(
name: name,
address: address,
description: description,
contactNumber: contactNumber,
manager: manager,
latitude: latitude,
longitude: longitude,
);
final result = await createWarehouseLocationUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(location) async => await refresh(),
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 수정
Future<void> updateWarehouseLocation({
required int id,
String? name,
String? address,
String? description,
String? contactNumber,
String? manager,
double? latitude,
double? longitude,
bool? isActive,
}) async {
try {
isLoadingState = true;
final params = UpdateWarehouseLocationParams(
id: id,
name: name,
address: address,
description: description,
contactNumber: contactNumber,
manager: manager,
latitude: latitude,
longitude: longitude,
isActive: isActive,
);
final result = await updateWarehouseLocationUseCase(params);
await result.fold(
(failure) async => errorState = failure.message,
(location) async => updateItemLocally(location, (item) => item.id == location.id),
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 삭제
Future<void> deleteWarehouseLocation(int id) async {
try {
isLoadingState = true;
final result = await deleteWarehouseLocationUseCase(id);
await result.fold(
(failure) async => errorState = failure.message,
(_) async {
removeItemLocally((item) => item.id == id);
_selectedLocationIds.remove(id);
},
);
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 창고 위치 활성/비활성 토글
Future<void> toggleLocationStatus(int id) async {
final location = items.firstWhere((item) => item.id == id);
await updateWarehouseLocation(
id: id,
isActive: !location.isActive,
);
}
/// 필터 설정
void setFilters({
bool? showActiveOnly,
String? manager,
}) {
if (showActiveOnly != null) _showActiveOnly = showActiveOnly;
_filterByManager = manager;
refresh();
}
/// 필터 초기화
void clearFilters() {
_showActiveOnly = true;
_filterByManager = null;
refresh();
}
/// 창고 위치 선택 토글
void toggleLocationSelection(int id) {
if (_selectedLocationIds.contains(id)) {
_selectedLocationIds.remove(id);
} else {
_selectedLocationIds.add(id);
}
notifyListeners();
}
/// 모든 창고 위치 선택
void selectAll() {
_selectedLocationIds.clear();
_selectedLocationIds.addAll(items.map((e) => e.id));
notifyListeners();
}
/// 선택 해제
void clearSelection() {
_selectedLocationIds.clear();
notifyListeners();
}
/// 선택된 창고 위치 일괄 삭제
Future<void> deleteSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
final errors = <String>[];
for (final id in _selectedLocationIds.toList()) {
final result = await deleteWarehouseLocationUseCase(id);
result.fold(
(failure) => errors.add('Location $id: ${failure.message}'),
(_) => removeItemLocally((item) => item.id == id),
);
}
_selectedLocationIds.clear();
if (errors.isNotEmpty) {
errorState = '일부 창고 위치 삭제 실패:\n${errors.join('\n')}';
}
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 선택된 창고 위치 일괄 활성화
Future<void> activateSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLocationIds.toList()) {
await updateWarehouseLocation(id: id, isActive: true);
}
_selectedLocationIds.clear();
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 선택된 창고 위치 일괄 비활성화
Future<void> deactivateSelectedLocations() async {
if (_selectedLocationIds.isEmpty) return;
try {
isLoadingState = true;
for (final id in _selectedLocationIds.toList()) {
await updateWarehouseLocation(id: id, isActive: false);
}
_selectedLocationIds.clear();
notifyListeners();
} catch (e) {
errorState = '오류 발생: $e';
} finally {
isLoadingState = false;
}
}
/// 드롭다운용 활성 창고 위치 목록 가져오기
List<WarehouseLocationDto> getActiveLocations() {
return items.where((location) => location.isActive).toList();
}
/// 특정 관리자의 창고 위치 목록 가져오기
List<WarehouseLocationDto> getLocationsByManager(String manager) {
return items.where((location) => location.managerName == manager).toList(); // managerName 필드 사용
}
@override
void dispose() {
_selectedLocationIds.clear();
super.dispose();
}
}

View File

@@ -31,6 +31,7 @@ class _WarehouseLocationListState
void initState() {
super.initState();
_controller = WarehouseLocationListController();
_controller.pageSize = 10; // 페이지 크기를 10으로 설정
// 초기 데이터 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_controller.loadWarehouseLocations();
@@ -145,8 +146,8 @@ class _WarehouseLocationListState
dataTable: _buildDataTable(pagedLocations),
// 페이지네이션
pagination: totalCount > controller.pageSize ? Pagination(
totalCount: totalCount,
pagination: controller.totalPages > 1 ? Pagination(
totalCount: controller.total,
currentPage: controller.currentPage,
pageSize: controller.pageSize,
onPageChanged: (page) {
@@ -162,7 +163,8 @@ class _WarehouseLocationListState
/// 데이터 테이블
Widget _buildDataTable(List<WarehouseLocation> pagedLocations) {
if (pagedLocations.isEmpty) {
// 전체 데이터가 없는지 확인 (API의 total 사용)
if (_controller.total == 0 && pagedLocations.isEmpty) {
return StandardEmptyState(
title:
_controller.searchQuery.isNotEmpty