고객사 목록 쿼리스트링 연동 및 공통 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,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),
],
);
}
}