고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입

This commit is contained in:
JiWoong Sul
2025-09-25 20:13:46 +09:00
parent 8a6ad1e81b
commit 900990c46b
27 changed files with 1458 additions and 176 deletions

View File

@@ -0,0 +1,68 @@
import 'package:superport_v2/core/common/utils/json_utils.dart';
import '../../domain/entities/postal_code.dart';
/// 우편번호 검색 API 응답을 표현하는 DTO.
class PostalCodeDto {
PostalCodeDto({
required this.zipcode,
this.sido,
this.sigungu,
this.roadName,
this.buildingMainNo,
this.buildingSubNo,
});
final String zipcode;
final String? sido;
final String? sigungu;
final String? roadName;
final int? buildingMainNo;
final int? buildingSubNo;
factory PostalCodeDto.fromJson(Map<String, dynamic> json) {
return PostalCodeDto(
zipcode: json['zipcode'] as String,
sido: json['sido'] as String?,
sigungu: json['sigungu'] as String?,
roadName: json['road_name'] as String?,
buildingMainNo: _parseInt(json['building_main_no']),
buildingSubNo: _parseInt(json['building_sub_no']),
);
}
PostalCode toEntity() {
return PostalCode(
zipcode: zipcode,
sido: sido,
sigungu: sigungu,
roadName: roadName,
buildingMainNo: buildingMainNo,
buildingSubNo: buildingSubNo,
);
}
static List<PostalCode> fromResponse(dynamic data) {
final items = JsonUtils.extractList(data, keys: const ['items', 'data']);
if (items.isEmpty) {
return const [];
}
return items
.map(PostalCodeDto.fromJson)
.map((dto) => dto.toEntity())
.toList(growable: false);
}
}
int? _parseInt(Object? value) {
if (value == null) {
return null;
}
if (value is int) {
return value;
}
if (value is String) {
return int.tryParse(value);
}
return null;
}

View File

@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/network/api_client.dart';
import '../../domain/entities/postal_code.dart';
import '../../domain/repositories/postal_search_repository.dart';
import '../dtos/postal_code_dto.dart';
/// 우편번호 검색 API를 호출하는 원격 저장소 구현체.
class PostalSearchRepositoryRemote implements PostalSearchRepository {
PostalSearchRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _path = '/zipcodes';
@override
Future<List<PostalCode>> search({
required String keyword,
int limit = 20,
}) async {
final trimmed = keyword.trim();
if (trimmed.isEmpty) {
return const [];
}
final response = await _api.get<dynamic>(
_path,
query: {
'zipcode': trimmed,
'road_name': trimmed,
'q': trimmed,
'page_size': limit,
},
options: Options(responseType: ResponseType.json),
);
return PostalCodeDto.fromResponse(response.data);
}
}

View File

@@ -0,0 +1,36 @@
/// 우편번호 검색 결과의 도메인 모델.
///
/// - `zipcode`: 5자리 우편번호.
/// - `sido`/`sigungu`: 시도 및 시군구 행정 구역.
/// - `roadName`: 도로명 주소 구성 요소.
/// - `buildingMainNo`/`buildingSubNo`: 건물 번호 본번/부번(없으면 null).
class PostalCode {
PostalCode({
required this.zipcode,
this.sido,
this.sigungu,
this.roadName,
this.buildingMainNo,
this.buildingSubNo,
});
final String zipcode;
final String? sido;
final String? sigungu;
final String? roadName;
final int? buildingMainNo;
final int? buildingSubNo;
/// 건물 번호 문자열을 반환한다. 본번만 존재하면 해당 값만 반환한다.
String get buildingNumber {
final main = buildingMainNo;
final sub = buildingSubNo;
if (main == null) {
return '';
}
if (sub == null || sub == 0) {
return '$main';
}
return '$main-$sub';
}
}

View File

@@ -0,0 +1,10 @@
import '../entities/postal_code.dart';
/// 우편번호 검색 기능을 추상화한 저장소 인터페이스.
abstract class PostalSearchRepository {
/// 키워드를 기반으로 우편번호 목록을 검색한다.
///
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
Future<List<PostalCode>> search({required String keyword, int limit = 20});
}

View File

@@ -0,0 +1,51 @@
import '../../domain/entities/postal_code.dart';
/// 우편번호 검색 결과를 표현하는 모델.
class PostalSearchResult {
PostalSearchResult({
required this.zipcode,
this.sido,
this.sigungu,
this.roadName,
this.buildingNumber,
});
final String zipcode;
final String? sido;
final String? sigungu;
final String? roadName;
final String? buildingNumber;
factory PostalSearchResult.fromPostalCode(PostalCode postalCode) {
final buildingValue = postalCode.buildingNumber;
return PostalSearchResult(
zipcode: postalCode.zipcode,
sido: postalCode.sido,
sigungu: postalCode.sigungu,
roadName: postalCode.roadName,
buildingNumber: buildingValue.isEmpty ? null : buildingValue,
);
}
/// 주소 구성요소를 공백으로 이어 붙인 표시 문자열.
String get fullAddress {
final segments = <String>[];
final sidoValue = sido;
if (sidoValue != null && sidoValue.isNotEmpty) {
segments.add(sidoValue);
}
final sigunguValue = sigungu;
if (sigunguValue != null && sigunguValue.isNotEmpty) {
segments.add(sigunguValue);
}
final roadNameValue = roadName;
if (roadNameValue != null && roadNameValue.isNotEmpty) {
segments.add(roadNameValue);
}
final buildingNumberValue = buildingNumber;
if (buildingNumberValue != null && buildingNumberValue.isNotEmpty) {
segments.add(buildingNumberValue);
}
return segments.join(' ');
}
}

View File

@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
import '../../domain/entities/postal_code.dart';
import '../../domain/repositories/postal_search_repository.dart';
import '../models/postal_search_result.dart';
typedef PostalSearchFetcher =
Future<List<PostalSearchResult>> Function(String keyword);
/// 우편번호 검색 모달을 노출한다.
Future<PostalSearchResult?> showPostalSearchDialog(
BuildContext context, {
PostalSearchFetcher? fetcher,
String? initialKeyword,
}) {
return showDialog<PostalSearchResult>(
context: context,
builder: (dialogContext) {
return _PostalSearchDialog(
fetcher: fetcher,
initialKeyword: initialKeyword,
);
},
);
}
class _PostalSearchDialog extends StatefulWidget {
const _PostalSearchDialog({required this.fetcher, this.initialKeyword});
final PostalSearchFetcher? fetcher;
final String? initialKeyword;
@override
State<_PostalSearchDialog> createState() => _PostalSearchDialogState();
}
class _PostalSearchDialogState extends State<_PostalSearchDialog> {
late final TextEditingController _keywordController;
final FocusNode _keywordFocus = FocusNode();
List<PostalSearchResult> _results = const [];
bool _isLoading = false;
bool _hasSearched = false;
String? _errorMessage;
@override
void initState() {
super.initState();
_keywordController = TextEditingController(text: widget.initialKeyword);
if (_keywordController.text.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_performSearch();
});
}
}
@override
void dispose() {
_keywordController.dispose();
_keywordFocus.dispose();
super.dispose();
}
Future<void> _performSearch() async {
final keyword = _keywordController.text.trim();
if (keyword.isEmpty) {
setState(() {
_results = const [];
_errorMessage = null;
_hasSearched = false;
});
return;
}
final fetcher = _resolveFetcher();
if (fetcher == null) {
setState(() {
_results = const [];
_errorMessage = '검색 기능이 아직 연결되지 않았습니다.';
_hasSearched = true;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_hasSearched = true;
});
try {
final items = await fetcher(keyword);
if (!mounted) return;
setState(() {
_results = items;
_errorMessage = null;
});
} catch (error) {
if (!mounted) return;
setState(() {
_results = const [];
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
PostalSearchFetcher? _resolveFetcher() {
final customFetcher = widget.fetcher;
if (customFetcher != null) {
return customFetcher;
}
final container = GetIt.instance;
if (!container.isRegistered<PostalSearchRepository>()) {
return null;
}
return (keyword) async {
final repository = container<PostalSearchRepository>();
final List<PostalCode> results = await repository.search(
keyword: keyword,
limit: 50,
);
return results
.map(PostalSearchResult.fromPostalCode)
.toList(growable: false);
};
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final navigator = Navigator.of(context);
return Dialog(
clipBehavior: Clip.antiAlias,
insetPadding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760, maxHeight: 600),
child: ShadCard(
title: Text('우편번호 검색', style: theme.textTheme.h3),
description: Text(
'도로명, 건물번호, 우편번호를 입력하면 검색 결과에서 직접 선택할 수 있습니다.',
style: theme.textTheme.muted,
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => navigator.pop(),
child: const Text('닫기'),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: ShadInput(
controller: _keywordController,
focusNode: _keywordFocus,
placeholder: const Text('도로명, 건물번호, 우편번호를 입력'),
leading: const Icon(LucideIcons.search, size: 16),
onSubmitted: (_) => _performSearch(),
),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _isLoading ? null : _performSearch,
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('검색'),
),
],
),
const SizedBox(height: 16),
Expanded(child: _buildResultArea(theme, navigator)),
],
),
),
),
);
}
Widget _buildResultArea(ShadThemeData theme, NavigatorState navigator) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.small,
),
),
);
}
if (_results.isEmpty) {
final label = _hasSearched
? '검색 결과가 없습니다. 다른 키워드로 시도해 주세요.'
: '검색어를 입력한 뒤 엔터 또는 검색 버튼을 눌러 주세요.';
return Center(child: Text(label, style: theme.textTheme.muted));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('검색 결과 ${_results.length}', style: theme.textTheme.muted),
const SizedBox(height: 8),
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SuperportTable(
columns: const [
Text('우편번호'),
Text('시도'),
Text('시군구'),
Text('도로명'),
Text('건물번호'),
],
rows: [
for (final item in _results)
[
Text(item.zipcode),
Text(item.sido ?? '-'),
Text(item.sigungu ?? '-'),
Text(item.roadName ?? '-'),
Text(item.buildingNumber ?? '-'),
],
],
onRowTap: (index) {
navigator.pop(_results[index]);
},
emptyLabel: '검색 결과가 없습니다.',
),
),
),
),
const SizedBox(height: 8),
Text('행을 클릭하면 선택한 주소가 부모 폼에 채워집니다.', style: theme.textTheme.small),
],
);
}
}