고객사 목록 쿼리스트링 연동 및 공통 JSON 파서 도입
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '../entities/postal_code.dart';
|
||||
|
||||
/// 우편번호 검색 기능을 추상화한 저장소 인터페이스.
|
||||
abstract class PostalSearchRepository {
|
||||
/// 키워드를 기반으로 우편번호 목록을 검색한다.
|
||||
///
|
||||
/// [keyword]는 우편번호/도로명/건물번호 중 하나의 문자열을 전달한다.
|
||||
/// [limit]을 지정하면 최대 반환 건수를 제한한다.
|
||||
Future<List<PostalCode>> search({required String keyword, int limit = 20});
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user