feat(app): seed restaurants, geocode addresses, refresh sharing
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `flutter pub get` – fetch packages after cloning or switching branches.
|
||||
- `flutter pub run build_runner build --delete-conflicting-outputs` – regenerate adapters and JSON code when models change.
|
||||
- `flutter pub run build_runner build --delete-conflicting-outputs` – regenerate adapters, JSON 코드, 그리고 `doc/restaurant_data/store.db` 변경분을 자동으로 변환/병합해 `assets/data/store_seed.json`·`store_seed.meta.json`을 갱신합니다. 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두면 store.db 수정 시마다 시드가 자동 재생성됩니다.
|
||||
- `flutter run -d ios|android|chrome` – start the app on the specified device; prefer simulators that can access location APIs.
|
||||
- `flutter build apk|appbundle|ios --release` – produce production bundles once QA is green.
|
||||
|
||||
|
||||
16535
assets/data/store_seed.json
Normal file
16535
assets/data/store_seed.json
Normal file
File diff suppressed because it is too large
Load Diff
10
assets/data/store_seed.meta.json
Normal file
10
assets/data/store_seed.meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "47e28144",
|
||||
"generatedAt": "2025-11-26T07:30:53.780901Z",
|
||||
"sourceDb": "doc/restaurant_data/store.db",
|
||||
"itemCount": 1503,
|
||||
"sourceSignature": {
|
||||
"hash": "47e28144",
|
||||
"size": 458752
|
||||
}
|
||||
}
|
||||
31
build.yaml
Normal file
31
build.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
targets:
|
||||
$default:
|
||||
sources:
|
||||
- $package$
|
||||
- lib/**
|
||||
- bin/**
|
||||
- test/**
|
||||
- web/**
|
||||
- example/**
|
||||
- doc/**
|
||||
- tool/**
|
||||
- assets/**
|
||||
- pubspec.yaml
|
||||
builders:
|
||||
lunchpick|store_seed_builder:
|
||||
enabled: true
|
||||
|
||||
builders:
|
||||
store_seed_builder:
|
||||
import: "package:lunchpick/builders/store_seed_builder.dart"
|
||||
builder_factories: ["storeSeedBuilder"]
|
||||
build_extensions:
|
||||
"doc/restaurant_data/store.db":
|
||||
- "assets/data/store_seed.json"
|
||||
- "assets/data/store_seed.meta.json"
|
||||
auto_apply: root_package
|
||||
build_to: source
|
||||
runs_before: ["source_gen|combining_builder"]
|
||||
defaults:
|
||||
generate_for:
|
||||
- doc/restaurant_data/store.db
|
||||
@@ -30,4 +30,11 @@
|
||||
- [개발 가이드](01_requirements/오늘%20뭐%20먹Z%3F%20완전한%20개발%20가이드.md)
|
||||
- [아키텍처 개요](03_architecture/architecture_overview.md)
|
||||
- [코드 컨벤션](03_architecture/code_convention.md)
|
||||
- [네이버 URL 처리 가이드](04_api/naver_short_url_guide.md)
|
||||
- [네이버 URL 처리 가이드](04_api/naver_short_url_guide.md)
|
||||
|
||||
## 데이터 시드 자동화
|
||||
|
||||
- `doc/restaurant_data/store.db`가 변경되면 `flutter pub run build_runner build --delete-conflicting-outputs` 또는 `watch`를 실행할 때마다 `assets/data/store_seed.json`과 `store_seed.meta.json`이 자동으로 재생성/병합됩니다(중복 제외, 해시 기반 버전 기록).
|
||||
- 개발 중에는 `flutter pub run build_runner watch --delete-conflicting-outputs`를 켜두고, CI/빌드 파이프라인에도 동일 명령을 pre-step으로 추가하면 배포 전에 항상 최신 시드가 패키징됩니다.
|
||||
|
||||
flutter run -d chrome --dart-define=KMA_SERVICE_KEY=MTg0Y2UzN2VlZmFjMGJlNWNmY2JjYWUyNmUxZDZlNjIzYmU5MDYyZmY3NDM5NjVlMzkwZmNkMzgzMGY3MTFiZg==
|
||||
|
||||
BIN
doc/restaurant_data/store.db
Normal file
BIN
doc/restaurant_data/store.db
Normal file
Binary file not shown.
192
lib/builders/store_seed_builder.dart
Normal file
192
lib/builders/store_seed_builder.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:build/build.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class StoreSeedBuilder implements Builder {
|
||||
StoreSeedBuilder();
|
||||
|
||||
@override
|
||||
final Map<String, List<String>> buildExtensions = const {
|
||||
'doc/restaurant_data/store.db': [
|
||||
'assets/data/store_seed.json',
|
||||
'assets/data/store_seed.meta.json',
|
||||
],
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> build(BuildStep buildStep) async {
|
||||
final inputId = buildStep.inputId;
|
||||
final bytes = await buildStep.readAsBytes(inputId);
|
||||
if (bytes.isEmpty) {
|
||||
log.warning('store.db가 비어 있습니다. 시드를 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final tempDir = await Directory.systemTemp.createTemp('store_seed_');
|
||||
final tempDbPath = p.join(tempDir.path, 'store.db');
|
||||
await File(tempDbPath).writeAsBytes(bytes, flush: true);
|
||||
|
||||
final sqlitePath = await _findSqliteBinary();
|
||||
if (sqlitePath == null) {
|
||||
log.severe('sqlite3 바이너리를 찾을 수 없습니다. 설치 후 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final rows = await _fetchRows(sqlitePath, tempDbPath);
|
||||
if (rows.isEmpty) {
|
||||
log.warning('restaurants 테이블에서 가져온 행이 없습니다.');
|
||||
await tempDir.delete(recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final newSeeds = rows.map(_seedFromMap).toList();
|
||||
final merged = await _mergeWithExisting(buildStep, newSeeds);
|
||||
|
||||
final signature = _buildSignature(bytes);
|
||||
final generatedAt = DateTime.now().toUtc().toIso8601String();
|
||||
final meta = {
|
||||
'version': signature,
|
||||
'generatedAt': generatedAt,
|
||||
'sourceDb': inputId.path,
|
||||
'itemCount': merged.length,
|
||||
'sourceSignature': {'hash': signature, 'size': bytes.length},
|
||||
};
|
||||
|
||||
final encoder = const JsonEncoder.withIndent(' ');
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.json'),
|
||||
'${encoder.convert(merged)}\n',
|
||||
);
|
||||
await buildStep.writeAsString(
|
||||
AssetId(inputId.package, 'assets/data/store_seed.meta.json'),
|
||||
'${encoder.convert(meta)}\n',
|
||||
);
|
||||
|
||||
await tempDir.delete(recursive: true);
|
||||
log.info(
|
||||
'store_seed 생성 완료: ${merged.length}개 (sig: $signature, src: ${inputId.path})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchRows(
|
||||
String sqlitePath,
|
||||
String dbPath,
|
||||
) async {
|
||||
const query =
|
||||
'SELECT id, province, district, name, title, address, road_address, '
|
||||
'latitude, longitude FROM restaurants';
|
||||
|
||||
final result = await Process.run(
|
||||
sqlitePath,
|
||||
['-json', dbPath, query],
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw StateError('sqlite3 실행 실패: ${result.stderr}');
|
||||
}
|
||||
|
||||
final output = result.stdout as String;
|
||||
final decoded = jsonDecode(output);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('예상치 못한 JSON 포맷입니다.');
|
||||
}
|
||||
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _seedFromMap(Map<String, dynamic> map) {
|
||||
return {
|
||||
'storeId': map['id'] as int,
|
||||
'province': (map['province'] as String).trim(),
|
||||
'district': (map['district'] as String).trim(),
|
||||
'name': (map['name'] as String).trim(),
|
||||
'title': (map['title'] as String).trim(),
|
||||
'address': (map['address'] as String).trim(),
|
||||
'roadAddress': (map['road_address'] as String).trim(),
|
||||
'latitude': (map['latitude'] as num).toDouble(),
|
||||
'longitude': (map['longitude'] as num).toDouble(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _mergeWithExisting(
|
||||
BuildStep buildStep,
|
||||
List<Map<String, dynamic>> newSeeds,
|
||||
) async {
|
||||
final existingId = AssetId(
|
||||
buildStep.inputId.package,
|
||||
'assets/data/store_seed.json',
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> existing = [];
|
||||
if (await buildStep.canRead(existingId)) {
|
||||
final raw = await buildStep.readAsString(existingId);
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
existing = decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (_) {
|
||||
log.warning('기존 store_seed.json 파싱 실패, 신규 데이터로 대체합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
final byId = <String, Map<String, dynamic>>{};
|
||||
for (final seed in existing) {
|
||||
final id = _seedId(seed);
|
||||
byId[id] = seed;
|
||||
}
|
||||
|
||||
for (final seed in newSeeds) {
|
||||
final id = _seedId(seed);
|
||||
if (!byId.containsKey(id)) {
|
||||
byId[id] = seed;
|
||||
continue;
|
||||
}
|
||||
|
||||
final isDuplicateByNameAndAddress = byId.values.any((existingSeed) {
|
||||
return existingSeed['name'] == seed['name'] &&
|
||||
existingSeed['roadAddress'] == seed['roadAddress'];
|
||||
});
|
||||
|
||||
if (!isDuplicateByNameAndAddress) {
|
||||
byId[id] = seed; // 같은 ID는 최신 값으로 교체
|
||||
}
|
||||
}
|
||||
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => (_seedId(a)).compareTo(_seedId(b)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
String _seedId(Map<String, dynamic> seed) => 'store-${seed['storeId']}';
|
||||
|
||||
Future<String?> _findSqliteBinary() async {
|
||||
try {
|
||||
final result = await Process.run('which', ['sqlite3']);
|
||||
if (result.exitCode == 0) {
|
||||
final path = (result.stdout as String).trim();
|
||||
if (path.isNotEmpty) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _buildSignature(List<int> bytes) {
|
||||
int hash = 0;
|
||||
for (final byte in bytes) {
|
||||
hash = (hash * 31 + byte) & 0x7fffffff;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
}
|
||||
|
||||
Builder storeSeedBuilder(BuilderOptions options) => StoreSeedBuilder();
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
@@ -12,6 +12,7 @@ class AppColors {
|
||||
static const lightError = Color(0xFFFF5252);
|
||||
static const lightText = Color(0xFF222222); // 추가
|
||||
static const lightCard = Colors.white; // 추가
|
||||
static const lightWarning = Color(0xFFFFA000);
|
||||
|
||||
// Dark Theme Colors
|
||||
static const darkPrimary = Color(0xFF03C75A);
|
||||
@@ -24,4 +25,5 @@ class AppColors {
|
||||
static const darkError = Color(0xFFFF5252);
|
||||
static const darkText = Color(0xFFFFFFFF); // 추가
|
||||
static const darkCard = Color(0xFF1E1E1E); // 추가
|
||||
static const darkWarning = Color(0xFFFFB74D);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class AppConstants {
|
||||
static const String visitRecordBox = 'visit_records';
|
||||
static const String recommendationBox = 'recommendations';
|
||||
static const String settingsBox = 'settings';
|
||||
static const String storeSeedVersionKey = 'store_seed_version';
|
||||
static const String storeSeedDataAsset = 'assets/data/store_seed.json';
|
||||
static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json';
|
||||
|
||||
// Default Settings
|
||||
static const int defaultDaysToExclude = 7;
|
||||
|
||||
58
lib/core/services/geocoding_service.dart
Normal file
58
lib/core/services/geocoding_service.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
|
||||
/// 주소를 위도/경도로 변환하는 간단한 지오코딩(Geocoding) 서비스
|
||||
class GeocodingService {
|
||||
static const _endpoint = 'https://nominatim.openstreetmap.org/search';
|
||||
static const _fallbackLatitude = 37.5665; // 서울시청 위도
|
||||
static const _fallbackLongitude = 126.9780; // 서울시청 경도
|
||||
|
||||
/// 도로명/지번 주소를 기반으로 위경도를 조회한다.
|
||||
///
|
||||
/// 무료(Nominatim) 엔드포인트를 사용하며 별도 API 키가 필요 없다.
|
||||
/// 실패 시 null을 반환하고, 호출 측에서 기본 좌표를 사용할 수 있게 둔다.
|
||||
Future<({double latitude, double longitude})?> geocode(String address) async {
|
||||
if (address.trim().isEmpty) return null;
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'$_endpoint?format=json&limit=1&q=${Uri.encodeQueryComponent(address)}',
|
||||
);
|
||||
|
||||
// Nominatim은 User-Agent 헤더를 요구한다.
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: const {'User-Agent': 'lunchpick-geocoder/1.0'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
AppLogger.debug('[GeocodingService] 실패 status: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<dynamic> results = jsonDecode(response.body) as List<dynamic>;
|
||||
if (results.isEmpty) return null;
|
||||
|
||||
final first = results.first as Map<String, dynamic>;
|
||||
final lat = double.tryParse(first['lat']?.toString() ?? '');
|
||||
final lon = double.tryParse(first['lon']?.toString() ?? '');
|
||||
|
||||
if (lat == null || lon == null) {
|
||||
AppLogger.debug('[GeocodingService] 응답 파싱 실패: ${first.toString()}');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (latitude: lat, longitude: lon);
|
||||
} catch (e) {
|
||||
AppLogger.debug('[GeocodingService] 예외 발생: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 좌표(서울시청)를 반환한다.
|
||||
({double latitude, double longitude}) defaultCoordinates() {
|
||||
return (latitude: _fallbackLatitude, longitude: _fallbackLongitude);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
businessHours: restaurant.businessHours,
|
||||
lastVisited: visitDate,
|
||||
visitCount: restaurant.visitCount + 1,
|
||||
needsAddressVerification: restaurant.needsAddressVerification,
|
||||
);
|
||||
await updateRestaurant(updatedRestaurant);
|
||||
}
|
||||
|
||||
@@ -177,13 +177,19 @@ class WeatherRepositoryImpl implements WeatherRepository {
|
||||
WeatherInfo _weatherInfoFromMap(Map<String, dynamic> map) {
|
||||
try {
|
||||
// current 필드 검증
|
||||
final currentMap = map['current'] as Map<String, dynamic>?;
|
||||
final currentRaw = map['current'];
|
||||
final currentMap = currentRaw is Map
|
||||
? Map<String, dynamic>.from(currentRaw)
|
||||
: null;
|
||||
if (currentMap == null) {
|
||||
throw FormatException('Missing current weather data');
|
||||
}
|
||||
|
||||
// nextHour 필드 검증
|
||||
final nextHourMap = map['nextHour'] as Map<String, dynamic>?;
|
||||
final nextHourRaw = map['nextHour'];
|
||||
final nextHourMap = nextHourRaw is Map
|
||||
? Map<String, dynamic>.from(nextHourRaw)
|
||||
: null;
|
||||
if (nextHourMap == null) {
|
||||
throw FormatException('Missing nextHour weather data');
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
|
||||
import 'store_dataset_seeder.dart';
|
||||
import 'manual_restaurant_samples.dart';
|
||||
|
||||
/// 초기 구동 시 샘플 데이터를 채워 넣는 도우미
|
||||
class SampleDataInitializer {
|
||||
static Future<void> seedInitialData() async {
|
||||
await StoreDatasetSeeder().seedIfNeeded();
|
||||
await seedManualRestaurantsIfNeeded();
|
||||
}
|
||||
|
||||
static Future<void> seedManualRestaurantsIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final visitBox = Hive.box<VisitRecord>(AppConstants.visitRecordBox);
|
||||
|
||||
243
lib/data/sample/store_dataset_seeder.dart
Normal file
243
lib/data/sample/store_dataset_seeder.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:lunchpick/core/constants/app_constants.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
class StoreSeedMeta {
|
||||
final String version;
|
||||
final DateTime generatedAt;
|
||||
final int itemCount;
|
||||
final StoreSeedSourceSignature? sourceSignature;
|
||||
|
||||
StoreSeedMeta({
|
||||
required this.version,
|
||||
required this.generatedAt,
|
||||
required this.itemCount,
|
||||
this.sourceSignature,
|
||||
});
|
||||
|
||||
factory StoreSeedMeta.fromJson(Map<String, dynamic> json) {
|
||||
StoreSeedSourceSignature? signature;
|
||||
if (json['sourceSignature'] != null) {
|
||||
signature = StoreSeedSourceSignature.fromJson(
|
||||
json['sourceSignature'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
return StoreSeedMeta(
|
||||
version: json['version'] as String,
|
||||
generatedAt: DateTime.parse(json['generatedAt'] as String),
|
||||
itemCount: json['itemCount'] as int,
|
||||
sourceSignature: signature,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedSourceSignature {
|
||||
final String hash;
|
||||
final int? size;
|
||||
|
||||
StoreSeedSourceSignature({required this.hash, this.size});
|
||||
|
||||
factory StoreSeedSourceSignature.fromJson(Map<String, dynamic> json) {
|
||||
return StoreSeedSourceSignature(
|
||||
hash: json['hash'] as String,
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedItem {
|
||||
final int storeId;
|
||||
final String name;
|
||||
final String title;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
|
||||
StoreSeedItem({
|
||||
required this.storeId,
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
});
|
||||
|
||||
factory StoreSeedItem.fromJson(Map<String, dynamic> json) {
|
||||
return StoreSeedItem(
|
||||
storeId: json['storeId'] as int,
|
||||
name: (json['name'] as String).trim(),
|
||||
title: (json['title'] as String).trim(),
|
||||
address: (json['address'] as String).trim(),
|
||||
roadAddress: (json['roadAddress'] as String).trim(),
|
||||
latitude: (json['latitude'] as num).toDouble(),
|
||||
longitude: (json['longitude'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreDatasetSeeder {
|
||||
Future<void> seedIfNeeded() async {
|
||||
final restaurantBox = Hive.box<Restaurant>(AppConstants.restaurantBox);
|
||||
final settingsBox = Hive.box(AppConstants.settingsBox);
|
||||
|
||||
final meta = await _loadMeta();
|
||||
if (meta == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentVersion =
|
||||
settingsBox.get(AppConstants.storeSeedVersionKey) as String?;
|
||||
final shouldSeed = restaurantBox.isEmpty || currentVersion != meta.version;
|
||||
if (!shouldSeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final seeds = await _loadSeedItems();
|
||||
if (seeds.isEmpty) {
|
||||
AppLogger.info('store_seed.json 데이터가 비어 있어 시드를 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await _applySeeds(
|
||||
restaurantBox: restaurantBox,
|
||||
seeds: seeds,
|
||||
generatedAt: meta.generatedAt,
|
||||
);
|
||||
await settingsBox.put(AppConstants.storeSeedVersionKey, meta.version);
|
||||
|
||||
AppLogger.info(
|
||||
'스토어 시드 적용 완료: version=${meta.version}, count=${meta.itemCount}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<StoreSeedMeta?> _loadMeta() async {
|
||||
try {
|
||||
final metaJson = await rootBundle.loadString(
|
||||
AppConstants.storeSeedMetaAsset,
|
||||
);
|
||||
final decoded = jsonDecode(metaJson) as Map<String, dynamic>;
|
||||
return StoreSeedMeta.fromJson(decoded);
|
||||
} catch (e, stack) {
|
||||
AppLogger.error(
|
||||
'store_seed.meta.json 로딩 실패',
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<StoreSeedItem>> _loadSeedItems() async {
|
||||
try {
|
||||
final dataJson = await rootBundle.loadString(
|
||||
AppConstants.storeSeedDataAsset,
|
||||
);
|
||||
final decoded = jsonDecode(dataJson);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('store_seed.json 포맷이 배열이 아닙니다.');
|
||||
}
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(StoreSeedItem.fromJson)
|
||||
.toList();
|
||||
} catch (e, stack) {
|
||||
AppLogger.error('store_seed.json 로딩 실패', error: e, stackTrace: stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applySeeds({
|
||||
required Box<Restaurant> restaurantBox,
|
||||
required List<StoreSeedItem> seeds,
|
||||
required DateTime generatedAt,
|
||||
}) async {
|
||||
final seedMap = {for (final seed in seeds) _buildId(seed.storeId): seed};
|
||||
int added = 0;
|
||||
int updated = 0;
|
||||
|
||||
for (final entry in seedMap.entries) {
|
||||
final id = entry.key;
|
||||
final seed = entry.value;
|
||||
final existing = restaurantBox.get(id);
|
||||
|
||||
if (existing == null) {
|
||||
final restaurant = _buildRestaurant(seed, generatedAt);
|
||||
await restaurantBox.put(id, restaurant);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.source == DataSource.PRESET) {
|
||||
final description = _buildDescription(seed, existing.description);
|
||||
final restaurant = existing.copyWith(
|
||||
name: seed.name,
|
||||
category: existing.category.isNotEmpty ? existing.category : '기타',
|
||||
subCategory: existing.subCategory.isNotEmpty
|
||||
? existing.subCategory
|
||||
: '기타',
|
||||
description: description,
|
||||
roadAddress: seed.roadAddress,
|
||||
jibunAddress: seed.address.isNotEmpty
|
||||
? seed.address
|
||||
: seed.roadAddress,
|
||||
latitude: seed.latitude,
|
||||
longitude: seed.longitude,
|
||||
updatedAt: generatedAt,
|
||||
);
|
||||
await restaurantBox.put(id, restaurant);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
final unchanged = restaurantBox.length - added - updated;
|
||||
AppLogger.debug(
|
||||
'스토어 시드 결과 - 추가: $added, 업데이트: $updated, 기존 유지: '
|
||||
'$unchanged',
|
||||
);
|
||||
}
|
||||
|
||||
Restaurant _buildRestaurant(StoreSeedItem seed, DateTime generatedAt) {
|
||||
return Restaurant(
|
||||
id: _buildId(seed.storeId),
|
||||
name: seed.name,
|
||||
category: '기타',
|
||||
subCategory: '기타',
|
||||
description: _buildDescription(seed, null),
|
||||
phoneNumber: null,
|
||||
roadAddress: seed.roadAddress,
|
||||
jibunAddress: seed.address.isNotEmpty ? seed.address : seed.roadAddress,
|
||||
latitude: seed.latitude,
|
||||
longitude: seed.longitude,
|
||||
lastVisitDate: null,
|
||||
source: DataSource.PRESET,
|
||||
createdAt: generatedAt,
|
||||
updatedAt: generatedAt,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: null,
|
||||
visitCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildId(int storeId) => 'store-$storeId';
|
||||
|
||||
String? _buildDescription(StoreSeedItem seed, String? existingDescription) {
|
||||
if (existingDescription != null && existingDescription.isNotEmpty) {
|
||||
return existingDescription;
|
||||
}
|
||||
|
||||
if (seed.title.isNotEmpty && seed.title != seed.name) {
|
||||
return seed.title;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ class Restaurant extends HiveObject {
|
||||
@HiveField(18)
|
||||
final int visitCount;
|
||||
|
||||
@HiveField(19)
|
||||
final bool needsAddressVerification;
|
||||
|
||||
Restaurant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -81,6 +84,7 @@ class Restaurant extends HiveObject {
|
||||
this.businessHours,
|
||||
this.lastVisited,
|
||||
this.visitCount = 0,
|
||||
this.needsAddressVerification = false,
|
||||
});
|
||||
|
||||
Restaurant copyWith({
|
||||
@@ -103,6 +107,7 @@ class Restaurant extends HiveObject {
|
||||
String? businessHours,
|
||||
DateTime? lastVisited,
|
||||
int? visitCount,
|
||||
bool? needsAddressVerification,
|
||||
}) {
|
||||
return Restaurant(
|
||||
id: id ?? this.id,
|
||||
@@ -124,6 +129,8 @@ class Restaurant extends HiveObject {
|
||||
businessHours: businessHours ?? this.businessHours,
|
||||
lastVisited: lastVisited ?? this.lastVisited,
|
||||
visitCount: visitCount ?? this.visitCount,
|
||||
needsAddressVerification:
|
||||
needsAddressVerification ?? this.needsAddressVerification,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,4 +142,7 @@ enum DataSource {
|
||||
|
||||
@HiveField(1)
|
||||
USER_INPUT,
|
||||
|
||||
@HiveField(2)
|
||||
PRESET,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ void main() async {
|
||||
await Hive.openBox<RecommendationRecord>(AppConstants.recommendationBox);
|
||||
await Hive.openBox(AppConstants.settingsBox);
|
||||
await Hive.openBox<UserSettings>('user_settings');
|
||||
await SampleDataInitializer.seedManualRestaurantsIfNeeded();
|
||||
await SampleDataInitializer.seedInitialData();
|
||||
|
||||
// Initialize Notification Service (only for non-web platforms)
|
||||
if (!kIsWeb) {
|
||||
|
||||
@@ -217,28 +217,31 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final locationAsync = ref.watch(
|
||||
currentLocationProvider,
|
||||
currentLocationWithFallbackProvider,
|
||||
);
|
||||
final restaurantsAsync = ref.watch(
|
||||
restaurantListProvider,
|
||||
);
|
||||
|
||||
if (locationAsync.hasValue &&
|
||||
restaurantsAsync.hasValue) {
|
||||
final location = locationAsync.value;
|
||||
final restaurants = restaurantsAsync.value;
|
||||
final location = locationAsync.maybeWhen(
|
||||
data: (pos) => pos,
|
||||
orElse: () => null,
|
||||
);
|
||||
final restaurants = restaurantsAsync.maybeWhen(
|
||||
data: (list) => list,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (location != null && restaurants != null) {
|
||||
final count = _getRestaurantCountInRange(
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
);
|
||||
return Text(
|
||||
'$count개 맛집 포함',
|
||||
style: AppTypography.caption(isDark),
|
||||
);
|
||||
}
|
||||
if (location != null && restaurants != null) {
|
||||
final count = _getRestaurantCountInRange(
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
);
|
||||
return Text(
|
||||
'$count개 맛집 포함',
|
||||
style: AppTypography.caption(isDark),
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
@@ -439,15 +442,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
|
||||
bool _canRecommend() {
|
||||
final locationAsync = ref.read(currentLocationProvider);
|
||||
final locationAsync = ref.read(currentLocationWithFallbackProvider);
|
||||
final restaurantsAsync = ref.read(restaurantListProvider);
|
||||
|
||||
if (!locationAsync.hasValue || !restaurantsAsync.hasValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final location = locationAsync.value;
|
||||
final restaurants = restaurantsAsync.value;
|
||||
final location = locationAsync.maybeWhen(
|
||||
data: (pos) => pos,
|
||||
orElse: () => null,
|
||||
);
|
||||
final restaurants = restaurantsAsync.maybeWhen(
|
||||
data: (list) => list,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (location == null || restaurants == null || restaurants.isEmpty) {
|
||||
return false;
|
||||
@@ -491,10 +496,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showSnack(
|
||||
'광고를 끝까지 시청해야 추천을 받을 수 있어요.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
_showSnack('광고를 끝까지 시청해야 추천을 받을 수 있어요.', type: _SnackType.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -502,10 +504,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
if (!mounted) return;
|
||||
await _showRecommendationDialog(candidate, recommendedAt: recommendedAt);
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'추천을 준비하는 중 문제가 발생했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
_showSnack('추천을 준비하는 중 문제가 발생했습니다.', type: _SnackType.error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -531,17 +530,14 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
|
||||
if (result.hasError) {
|
||||
final message = result.error?.toString() ?? '알 수 없는 오류';
|
||||
_showSnack(
|
||||
'추천 중 오류가 발생했습니다: $message',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
_showSnack('추천 중 오류가 발생했습니다: $message', type: _SnackType.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recommendation == null) {
|
||||
_showSnack(
|
||||
'조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
type: _SnackType.warning,
|
||||
);
|
||||
}
|
||||
return recommendation;
|
||||
@@ -626,10 +622,10 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
|
||||
_showSnack(
|
||||
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
type: _SnackType.warning,
|
||||
);
|
||||
} else {
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
_showSnack('맛있게 드세요! 🍴', type: _SnackType.success);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -637,25 +633,25 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'방문 기록 또는 알림 예약에 실패했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
_showSnack('방문 기록 또는 알림 예약에 실패했습니다.', type: _SnackType.error);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(
|
||||
String message, {
|
||||
Color backgroundColor = AppColors.lightPrimary,
|
||||
}) {
|
||||
void _showSnack(String message, {_SnackType type = _SnackType.info}) {
|
||||
if (!mounted) return;
|
||||
final bgColor = switch (type) {
|
||||
_SnackType.success => Colors.teal.shade600,
|
||||
_SnackType.warning => Colors.orange.shade600,
|
||||
_SnackType.error => AppColors.lightError,
|
||||
_SnackType.info => Colors.blueGrey.shade600,
|
||||
};
|
||||
final topInset = MediaQuery.of(context).viewPadding.top;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
backgroundColor: bgColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
@@ -668,3 +664,5 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _SnackType { info, warning, error, success }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
@@ -31,10 +32,11 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||
final restaurantsAsync = ref.watch(
|
||||
searchQuery.isNotEmpty || selectedCategory != null
|
||||
isFiltered
|
||||
? filteredRestaurantsProvider
|
||||
: restaurantListProvider,
|
||||
: sortedRestaurantsByDistanceProvider,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
@@ -103,15 +105,30 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
// 맛집 목록
|
||||
Expanded(
|
||||
child: restaurantsAsync.when(
|
||||
data: (restaurants) {
|
||||
if (restaurants.isEmpty) {
|
||||
data: (restaurantsData) {
|
||||
final items = isFiltered
|
||||
? (restaurantsData as List<Restaurant>)
|
||||
.map(
|
||||
(r) => (restaurant: r, distanceKm: null as double?),
|
||||
)
|
||||
.toList()
|
||||
: restaurantsData
|
||||
as List<
|
||||
({Restaurant restaurant, double? distanceKm})
|
||||
>;
|
||||
|
||||
if (items.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: restaurants.length,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RestaurantCard(restaurant: restaurants[index]);
|
||||
final item = items[index];
|
||||
return RestaurantCard(
|
||||
restaurant: item.restaurant,
|
||||
distanceKm: item.distanceKm,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -241,25 +258,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
_addByNaverLink();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
title: const Text('상호명으로 검색'),
|
||||
subtitle: const Text('가게 이름으로 검색하여 추가'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addBySearch();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -292,14 +290,6 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBySearch() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const AddRestaurantDialog(mode: AddRestaurantDialogMode.search),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addManually() async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const ManualRestaurantInputScreen()),
|
||||
|
||||
@@ -223,7 +223,7 @@ class AddRestaurantForm extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
|
||||
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
|
||||
import 'add_restaurant_form.dart';
|
||||
|
||||
/// 기존 맛집 정보를 편집하는 다이얼로그
|
||||
class EditRestaurantDialog extends ConsumerStatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
|
||||
const EditRestaurantDialog({super.key, required this.restaurant});
|
||||
|
||||
@override
|
||||
ConsumerState<EditRestaurantDialog> createState() =>
|
||||
_EditRestaurantDialogState();
|
||||
}
|
||||
|
||||
class _EditRestaurantDialogState extends ConsumerState<EditRestaurantDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _categoryController;
|
||||
late final TextEditingController _subCategoryController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _roadAddressController;
|
||||
late final TextEditingController _jibunAddressController;
|
||||
late final TextEditingController _latitudeController;
|
||||
late final TextEditingController _longitudeController;
|
||||
|
||||
bool _isSaving = false;
|
||||
late final String _originalRoadAddress;
|
||||
late final String _originalJibunAddress;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final restaurant = widget.restaurant;
|
||||
_nameController = TextEditingController(text: restaurant.name);
|
||||
_categoryController = TextEditingController(text: restaurant.category);
|
||||
_subCategoryController = TextEditingController(
|
||||
text: restaurant.subCategory,
|
||||
);
|
||||
_descriptionController = TextEditingController(
|
||||
text: restaurant.description ?? '',
|
||||
);
|
||||
_phoneController = TextEditingController(
|
||||
text: restaurant.phoneNumber ?? '',
|
||||
);
|
||||
_roadAddressController = TextEditingController(
|
||||
text: restaurant.roadAddress,
|
||||
);
|
||||
_jibunAddressController = TextEditingController(
|
||||
text: restaurant.jibunAddress,
|
||||
);
|
||||
_latitudeController = TextEditingController(
|
||||
text: restaurant.latitude.toString(),
|
||||
);
|
||||
_longitudeController = TextEditingController(
|
||||
text: restaurant.longitude.toString(),
|
||||
);
|
||||
_originalRoadAddress = restaurant.roadAddress;
|
||||
_originalJibunAddress = restaurant.jibunAddress;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_categoryController.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_phoneController.dispose();
|
||||
_roadAddressController.dispose();
|
||||
_jibunAddressController.dispose();
|
||||
_latitudeController.dispose();
|
||||
_longitudeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged(String _) {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_formKey.currentState?.validate() != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
final addressChanged =
|
||||
_roadAddressController.text.trim() != _originalRoadAddress ||
|
||||
_jibunAddressController.text.trim() != _originalJibunAddress;
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: _latitudeController.text.trim(),
|
||||
longitudeText: _longitudeController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim(),
|
||||
forceRecalculate: addressChanged,
|
||||
);
|
||||
|
||||
_latitudeController.text = coords.latitude.toString();
|
||||
_longitudeController.text = coords.longitude.toString();
|
||||
|
||||
final updatedRestaurant = widget.restaurant.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
category: _categoryController.text.trim(),
|
||||
subCategory: _subCategoryController.text.trim().isEmpty
|
||||
? _categoryController.text.trim()
|
||||
: _subCategoryController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
phoneNumber: _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim(),
|
||||
roadAddress: _roadAddressController.text.trim(),
|
||||
jibunAddress: _jibunAddressController.text.trim().isEmpty
|
||||
? _roadAddressController.text.trim()
|
||||
: _jibunAddressController.text.trim(),
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
updatedAt: DateTime.now(),
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.updateRestaurant(updatedRestaurant);
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop(true);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: const [
|
||||
Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('맛집 정보가 업데이트되었습니다'),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('수정에 실패했습니다: $e'),
|
||||
backgroundColor: AppColors.lightError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'맛집 정보 수정',
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AddRestaurantForm(
|
||||
formKey: _formKey,
|
||||
nameController: _nameController,
|
||||
categoryController: _categoryController,
|
||||
subCategoryController: _subCategoryController,
|
||||
descriptionController: _descriptionController,
|
||||
phoneController: _phoneController,
|
||||
roadAddressController: _roadAddressController,
|
||||
jibunAddressController: _jibunAddressController,
|
||||
latitudeController: _latitudeController,
|
||||
longitudeController: _longitudeController,
|
||||
onFieldChanged: _onFieldChanged,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
||||
_resolveCoordinates({
|
||||
required String latitudeText,
|
||||
required String longitudeText,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
bool forceRecalculate = false,
|
||||
}) async {
|
||||
if (!forceRecalculate) {
|
||||
final parsedLat = double.tryParse(latitudeText);
|
||||
final parsedLon = double.tryParse(longitudeText);
|
||||
if (parsedLat != null && parsedLon != null) {
|
||||
return (
|
||||
latitude: parsedLat,
|
||||
longitude: parsedLon,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final geocodingService = ref.read(geocodingServiceProvider);
|
||||
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
||||
if (address.isNotEmpty) {
|
||||
final result = await geocodingService.geocode(address);
|
||||
if (result != null) {
|
||||
return (
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final position = await ref.read(currentLocationProvider.future);
|
||||
if (position != null) {
|
||||
return (
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final fallback = geocodingService.defaultCoordinates();
|
||||
return (
|
||||
latitude: fallback.latitude,
|
||||
longitude: fallback.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/visit_provider.dart';
|
||||
import 'edit_restaurant_dialog.dart';
|
||||
|
||||
class RestaurantCard extends ConsumerWidget {
|
||||
final Restaurant restaurant;
|
||||
final double? distanceKm;
|
||||
|
||||
const RestaurantCard({super.key, required this.restaurant});
|
||||
const RestaurantCard({super.key, required this.restaurant, this.distanceKm});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -49,41 +51,94 @@ class RestaurantCard extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory !=
|
||||
restaurant.category) ...[
|
||||
Text(' • ', style: AppTypography.body2(isDark)),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading2(isDark),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
if (restaurant.subCategory !=
|
||||
restaurant.category) ...[
|
||||
Text(
|
||||
' • ',
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
Text(
|
||||
restaurant.subCategory,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 더보기 버튼
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
onPressed: () => _showOptions(context, ref, isDark),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_BadgesRow(
|
||||
distanceKm: distanceKm,
|
||||
needsAddressVerification:
|
||||
restaurant.needsAddressVerification,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 더보기 버튼
|
||||
PopupMenuButton<_RestaurantMenuAction>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
offset: const Offset(0, 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onSelected: (action) =>
|
||||
_handleMenuAction(action, context, ref),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: _RestaurantMenuAction.edit,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
SizedBox(width: 8),
|
||||
Text('수정'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _RestaurantMenuAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: AppColors.lightError),
|
||||
SizedBox(width: 8),
|
||||
Text('삭제'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -240,75 +295,172 @@ class RestaurantCard extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions(BuildContext context, WidgetRef ref, bool isDark) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? AppColors.darkDivider
|
||||
: AppColors.lightDivider,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
void _handleMenuAction(
|
||||
_RestaurantMenuAction action,
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
switch (action) {
|
||||
case _RestaurantMenuAction.edit:
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => EditRestaurantDialog(restaurant: restaurant),
|
||||
);
|
||||
break;
|
||||
case _RestaurantMenuAction.delete:
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('맛집 삭제'),
|
||||
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'삭제',
|
||||
style: TextStyle(color: AppColors.lightError),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit, color: AppColors.lightPrimary),
|
||||
title: const Text('수정'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: 수정 기능 구현
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: AppColors.lightError),
|
||||
title: const Text('삭제'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('맛집 삭제'),
|
||||
content: Text('${restaurant.name}을(를) 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'삭제',
|
||||
style: TextStyle(color: AppColors.lightError),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(restaurantNotifierProvider.notifier)
|
||||
.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DistanceBadge extends StatelessWidget {
|
||||
final double distanceKm;
|
||||
final bool isDark;
|
||||
|
||||
const _DistanceBadge({required this.distanceKm, required this.isDark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = _formatDistance(distanceKm);
|
||||
final isFar = distanceKm * 1000 >= 2000;
|
||||
final Color bgColor;
|
||||
final Color textColor;
|
||||
|
||||
if (isFar) {
|
||||
bgColor = isDark
|
||||
? AppColors.darkError.withOpacity(0.15)
|
||||
: AppColors.lightError.withOpacity(0.15);
|
||||
textColor = AppColors.lightError;
|
||||
} else {
|
||||
bgColor = isDark
|
||||
? AppColors.darkPrimary.withOpacity(0.12)
|
||||
: AppColors.lightPrimary.withOpacity(0.12);
|
||||
textColor = AppColors.lightPrimary;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.place, size: 16, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.caption(
|
||||
isDark,
|
||||
).copyWith(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDistance(double distanceKm) {
|
||||
final meters = distanceKm * 1000;
|
||||
if (meters >= 2000) {
|
||||
return '2.0km 이상';
|
||||
}
|
||||
if (meters >= 1000) {
|
||||
return '${distanceKm.toStringAsFixed(1)}km';
|
||||
}
|
||||
return '${meters.round()}m';
|
||||
}
|
||||
}
|
||||
|
||||
enum _RestaurantMenuAction { edit, delete }
|
||||
|
||||
class _BadgesRow extends StatelessWidget {
|
||||
final double? distanceKm;
|
||||
final bool needsAddressVerification;
|
||||
final bool isDark;
|
||||
|
||||
const _BadgesRow({
|
||||
required this.distanceKm,
|
||||
required this.needsAddressVerification,
|
||||
required this.isDark,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final badges = <Widget>[];
|
||||
if (needsAddressVerification) {
|
||||
badges.add(_AddressVerificationChip(isDark: isDark));
|
||||
}
|
||||
if (distanceKm != null) {
|
||||
badges.add(_DistanceBadge(distanceKm: distanceKm!, isDark: isDark));
|
||||
}
|
||||
|
||||
if (badges.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.end,
|
||||
children: badges,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressVerificationChip extends StatelessWidget {
|
||||
final bool isDark;
|
||||
|
||||
const _AddressVerificationChip({required this.isDark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isDark
|
||||
? AppColors.darkError.withOpacity(0.12)
|
||||
: AppColors.lightError.withOpacity(0.12);
|
||||
final textColor = isDark ? AppColors.darkError : AppColors.lightError;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'주소확인',
|
||||
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,63 @@ class ShareScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ShareScreen> createState() => _ShareScreenState();
|
||||
}
|
||||
|
||||
class _ShareCard extends StatelessWidget {
|
||||
final bool isDark;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color iconBgColor;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
const _ShareCard({
|
||||
required this.isDark,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.iconBgColor,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: iconBgColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 48, color: iconColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
String? _shareCode;
|
||||
bool _isScanning = false;
|
||||
@@ -62,233 +119,180 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 공유받기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.download_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('리스트 공유받기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'다른 사람의 맛집 리스트를 받아보세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_shareCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 6,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'이 코드를 상대방에게 알려주세요',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_generateShareCode();
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('공유 코드 생성'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.upload_rounded,
|
||||
iconColor: AppColors.lightSecondary,
|
||||
iconBgColor: AppColors.lightSecondary.withOpacity(0.1),
|
||||
title: '내 리스트 공유하기',
|
||||
subtitle: '내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
child: _buildSendSection(isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 공유하기 섹션
|
||||
Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightSecondary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.upload_rounded,
|
||||
size: 48,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('내 리스트 공유하기', style: AppTypography.heading2(isDark)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'내 맛집 리스트를 다른 사람과 공유하세요',
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_isScanning && _nearbyDevices != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: _nearbyDevices!.isEmpty
|
||||
? Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _nearbyDevices!.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _nearbyDevices![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.phone_android,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
title: Text(
|
||||
device.code,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'기기 ID: ${device.deviceId}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.send,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
onTap: () {
|
||||
_sendList(device.code);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('스캔 중지'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.lightError,
|
||||
),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_scanDevices();
|
||||
},
|
||||
icon: const Icon(Icons.radar),
|
||||
label: const Text('주변 기기 스캔'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightSecondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.download_rounded,
|
||||
iconColor: AppColors.lightPrimary,
|
||||
iconBgColor: AppColors.lightPrimary.withOpacity(0.1),
|
||||
title: '리스트 공유받기',
|
||||
subtitle: '다른 사람의 맛집 리스트를 받아보세요',
|
||||
child: _buildReceiveSection(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiveSection(bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_shareCode != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.lightPrimary.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_shareCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 6,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('이 코드를 상대방에게 알려주세요', style: AppTypography.caption(isDark)),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
});
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('취소'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_generateShareCode();
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: const Text('공유 코드 생성'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendSection(bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_isScanning && _nearbyDevices != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: _nearbyDevices!.isEmpty
|
||||
? Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'주변 기기를 검색 중...',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _nearbyDevices!.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _nearbyDevices![index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.phone_android,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
title: Text(
|
||||
device.code,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text('기기 ID: ${device.deviceId}'),
|
||||
trailing: const Icon(
|
||||
Icons.send,
|
||||
color: AppColors.lightSecondary,
|
||||
),
|
||||
onTap: () {
|
||||
_sendList(device.code);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.stop),
|
||||
label: const Text('스캔 중지'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.lightError),
|
||||
),
|
||||
] else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_scanDevices();
|
||||
},
|
||||
icon: const Icon(Icons.radar),
|
||||
label: const Text('주변 기기 스캔'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightSecondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/recommendation_repository.dart';
|
||||
import 'package:lunchpick/core/services/geocoding_service.dart';
|
||||
|
||||
/// RestaurantRepository Provider
|
||||
final restaurantRepositoryProvider = Provider<RestaurantRepository>((ref) {
|
||||
@@ -57,3 +58,8 @@ final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
|
||||
final parser = ref.watch(naverMapParserProvider);
|
||||
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
|
||||
});
|
||||
|
||||
/// GeocodingService Provider
|
||||
final geocodingServiceProvider = Provider<GeocodingService>((ref) {
|
||||
return GeocodingService();
|
||||
});
|
||||
|
||||
@@ -78,6 +78,28 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
/// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고,
|
||||
/// 이후 실제 위치 스트림이 들어오면 업데이트하는 Provider.
|
||||
final currentLocationWithFallbackProvider = StreamProvider<Position>((
|
||||
ref,
|
||||
) async* {
|
||||
final initial = await Future.any([
|
||||
ref
|
||||
.watch(currentLocationProvider.future)
|
||||
.then((pos) => pos ?? defaultPosition()),
|
||||
Future<Position>.delayed(
|
||||
const Duration(seconds: 3),
|
||||
() => defaultPosition(),
|
||||
),
|
||||
]).catchError((_) => defaultPosition());
|
||||
|
||||
yield initial;
|
||||
|
||||
yield* ref.watch(locationStreamProvider.stream).handleError((_) {
|
||||
// 스트림 오류는 무시하고 마지막 위치를 유지
|
||||
});
|
||||
});
|
||||
|
||||
/// 위치 관리 StateNotifier
|
||||
class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
LocationNotifier() : super(const AsyncValue.loading());
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:lunchpick/core/utils/category_mapper.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
import 'package:lunchpick/core/utils/distance_calculator.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 맛집 목록 Provider
|
||||
@@ -11,6 +13,35 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
||||
return repository.watchRestaurants();
|
||||
});
|
||||
|
||||
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
|
||||
final sortedRestaurantsByDistanceProvider =
|
||||
StreamProvider<List<({Restaurant restaurant, double? distanceKm})>>((ref) {
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
final positionAsync = ref.watch(currentLocationProvider);
|
||||
final position = positionAsync.maybeWhen(
|
||||
data: (pos) => pos ?? defaultPosition(),
|
||||
orElse: () => defaultPosition(),
|
||||
);
|
||||
|
||||
return restaurantsStream.map((restaurants) {
|
||||
final sorted =
|
||||
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
|
||||
final distanceKm = DistanceCalculator.calculateDistance(
|
||||
lat1: position.latitude,
|
||||
lon1: position.longitude,
|
||||
lat2: r.latitude,
|
||||
lon2: r.longitude,
|
||||
);
|
||||
return (restaurant: r, distanceKm: distanceKm);
|
||||
}).toList()..sort(
|
||||
(a, b) => (a.distanceKm ?? double.infinity).compareTo(
|
||||
b.distanceKm ?? double.infinity,
|
||||
),
|
||||
);
|
||||
return sorted;
|
||||
});
|
||||
});
|
||||
|
||||
/// 특정 맛집 Provider
|
||||
final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
||||
ref,
|
||||
@@ -20,10 +51,14 @@ final restaurantProvider = FutureProvider.family<Restaurant?, String>((
|
||||
return repository.getRestaurantById(id);
|
||||
});
|
||||
|
||||
/// 카테고리 목록 Provider
|
||||
final categoriesProvider = FutureProvider<List<String>>((ref) async {
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
return repository.getAllCategories();
|
||||
/// 카테고리 목록 Provider (맛집 스트림을 구독해 즉시 갱신)
|
||||
final categoriesProvider = StreamProvider<List<String>>((ref) {
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
return restaurantsStream.map((restaurants) {
|
||||
final categories = restaurants.map((r) => r.category).toSet().toList()
|
||||
..sort();
|
||||
return categories;
|
||||
});
|
||||
});
|
||||
|
||||
/// 맛집 관리 StateNotifier
|
||||
@@ -76,24 +111,12 @@ class RestaurantNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final updated = Restaurant(
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
subCategory: restaurant.subCategory,
|
||||
description: restaurant.description,
|
||||
phoneNumber: restaurant.phoneNumber,
|
||||
roadAddress: restaurant.roadAddress,
|
||||
jibunAddress: restaurant.jibunAddress,
|
||||
latitude: restaurant.latitude,
|
||||
longitude: restaurant.longitude,
|
||||
lastVisitDate: restaurant.lastVisitDate,
|
||||
source: restaurant.source,
|
||||
createdAt: restaurant.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
final nextSource = restaurant.source == DataSource.PRESET
|
||||
? DataSource.USER_INPUT
|
||||
: restaurant.source;
|
||||
await _repository.updateRestaurant(
|
||||
restaurant.copyWith(source: nextSource, updatedAt: DateTime.now()),
|
||||
);
|
||||
|
||||
await _repository.updateRestaurant(updated);
|
||||
state = const AsyncValue.data(null);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../../domain/entities/restaurant.dart';
|
||||
import '../providers/di_providers.dart';
|
||||
import '../providers/restaurant_provider.dart';
|
||||
import '../providers/location_provider.dart';
|
||||
|
||||
/// 식당 추가 화면의 상태 모델
|
||||
class AddRestaurantState {
|
||||
@@ -248,6 +249,15 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
// 네이버에서 가져온 데이터가 있으면 업데이트
|
||||
final fetchedData = state.fetchedRestaurantData;
|
||||
if (fetchedData != null) {
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: state.formData.latitude,
|
||||
longitudeText: state.formData.longitude,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress,
|
||||
fallbackLatitude: fetchedData.latitude,
|
||||
fallbackLongitude: fetchedData.longitude,
|
||||
);
|
||||
|
||||
restaurantToSave = fetchedData.copyWith(
|
||||
name: state.formData.name,
|
||||
category: state.formData.category,
|
||||
@@ -264,19 +274,28 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
jibunAddress: state.formData.jibunAddress.isEmpty
|
||||
? state.formData.roadAddress
|
||||
: state.formData.jibunAddress,
|
||||
latitude:
|
||||
double.tryParse(state.formData.latitude) ?? fetchedData.latitude,
|
||||
longitude:
|
||||
double.tryParse(state.formData.longitude) ??
|
||||
fetchedData.longitude,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
naverUrl: state.formData.naverUrl.isEmpty
|
||||
? null
|
||||
: state.formData.naverUrl,
|
||||
updatedAt: DateTime.now(),
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
} else {
|
||||
// 직접 입력한 경우
|
||||
restaurantToSave = state.formData.toRestaurant();
|
||||
final coords = await _resolveCoordinates(
|
||||
latitudeText: state.formData.latitude,
|
||||
longitudeText: state.formData.longitude,
|
||||
roadAddress: state.formData.roadAddress,
|
||||
jibunAddress: state.formData.jibunAddress,
|
||||
);
|
||||
|
||||
restaurantToSave = state.formData.toRestaurant().copyWith(
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
needsAddressVerification: coords.usedCurrentLocation,
|
||||
);
|
||||
}
|
||||
|
||||
await notifier.addRestaurantDirect(restaurantToSave);
|
||||
@@ -297,6 +316,68 @@ class AddRestaurantViewModel extends StateNotifier<AddRestaurantState> {
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
|
||||
Future<({double latitude, double longitude, bool usedCurrentLocation})>
|
||||
_resolveCoordinates({
|
||||
required String latitudeText,
|
||||
required String longitudeText,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
double? fallbackLatitude,
|
||||
double? fallbackLongitude,
|
||||
}) async {
|
||||
final parsedLat = double.tryParse(latitudeText);
|
||||
final parsedLon = double.tryParse(longitudeText);
|
||||
if (parsedLat != null && parsedLon != null) {
|
||||
return (
|
||||
latitude: parsedLat,
|
||||
longitude: parsedLon,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
|
||||
final geocodingService = _ref.read(geocodingServiceProvider);
|
||||
final address = roadAddress.isNotEmpty ? roadAddress : jibunAddress;
|
||||
if (address.isNotEmpty) {
|
||||
final result = await geocodingService.geocode(address);
|
||||
if (result != null) {
|
||||
return (
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 주소로 좌표를 얻지 못하면 현재 위치를 활용한다.
|
||||
try {
|
||||
final position = await _ref.read(currentLocationProvider.future);
|
||||
if (position != null) {
|
||||
return (
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 위치 권한 거부/오류 시 fallback 사용
|
||||
}
|
||||
|
||||
if (fallbackLatitude != null && fallbackLongitude != null) {
|
||||
return (
|
||||
latitude: fallbackLatitude,
|
||||
longitude: fallbackLongitude,
|
||||
usedCurrentLocation: false,
|
||||
);
|
||||
}
|
||||
|
||||
final defaultCoords = geocodingService.defaultCoordinates();
|
||||
return (
|
||||
latitude: defaultCoords.latitude,
|
||||
longitude: defaultCoords.longitude,
|
||||
usedCurrentLocation: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AddRestaurantViewModel Provider
|
||||
|
||||
@@ -33,6 +33,7 @@ class CategorySelector extends ConsumerWidget {
|
||||
height: 50,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
if (showAllOption && !multiSelect) ...[
|
||||
_buildCategoryChip(
|
||||
|
||||
@@ -108,10 +108,9 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/data/store_seed.json
|
||||
- assets/data/store_seed.meta.json
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
229
tool/store_db_to_seed.dart
Normal file
229
tool/store_db_to_seed.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
class StoreSeedConfig {
|
||||
final String dbPath;
|
||||
final String dataOutputPath;
|
||||
final String metaOutputPath;
|
||||
final String version;
|
||||
|
||||
StoreSeedConfig({
|
||||
required this.dbPath,
|
||||
required this.dataOutputPath,
|
||||
required this.metaOutputPath,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
factory StoreSeedConfig.fromArgs(List<String> args) {
|
||||
String dbPath = 'doc/restaurant_data/store.db';
|
||||
String dataOutputPath = 'assets/data/store_seed.json';
|
||||
String metaOutputPath = 'assets/data/store_seed.meta.json';
|
||||
String version = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
for (final arg in args) {
|
||||
if (arg.startsWith('--db=')) {
|
||||
dbPath = arg.substring('--db='.length);
|
||||
} else if (arg.startsWith('--data=')) {
|
||||
dataOutputPath = arg.substring('--data='.length);
|
||||
} else if (arg.startsWith('--meta=')) {
|
||||
metaOutputPath = arg.substring('--meta='.length);
|
||||
} else if (arg.startsWith('--version=')) {
|
||||
version = arg.substring('--version='.length);
|
||||
}
|
||||
}
|
||||
|
||||
return StoreSeedConfig(
|
||||
dbPath: dbPath,
|
||||
dataOutputPath: dataOutputPath,
|
||||
metaOutputPath: metaOutputPath,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedRow {
|
||||
final int storeId;
|
||||
final String province;
|
||||
final String district;
|
||||
final String name;
|
||||
final String title;
|
||||
final String address;
|
||||
final String roadAddress;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
|
||||
StoreSeedRow({
|
||||
required this.storeId,
|
||||
required this.province,
|
||||
required this.district,
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.address,
|
||||
required this.roadAddress,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
});
|
||||
|
||||
factory StoreSeedRow.fromMap(Map<String, dynamic> map) {
|
||||
return StoreSeedRow(
|
||||
storeId: map['id'] as int,
|
||||
province: (map['province'] as String).trim(),
|
||||
district: (map['district'] as String).trim(),
|
||||
name: (map['name'] as String).trim(),
|
||||
title: (map['title'] as String).trim(),
|
||||
address: (map['address'] as String).trim(),
|
||||
roadAddress: (map['road_address'] as String).trim(),
|
||||
latitude: (map['latitude'] as num).toDouble(),
|
||||
longitude: (map['longitude'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'storeId': storeId,
|
||||
'province': province,
|
||||
'district': district,
|
||||
'name': name,
|
||||
'title': title,
|
||||
'address': address,
|
||||
'roadAddress': roadAddress,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StoreSeedMeta {
|
||||
final String version;
|
||||
final String generatedAt;
|
||||
final String sourceDb;
|
||||
final int itemCount;
|
||||
final Map<String, dynamic> sourceSignature;
|
||||
|
||||
StoreSeedMeta({
|
||||
required this.version,
|
||||
required this.generatedAt,
|
||||
required this.sourceDb,
|
||||
required this.itemCount,
|
||||
required this.sourceSignature,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'version': version,
|
||||
'generatedAt': generatedAt,
|
||||
'sourceDb': sourceDb,
|
||||
'itemCount': itemCount,
|
||||
'sourceSignature': sourceSignature,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
final config = StoreSeedConfig.fromArgs(args);
|
||||
|
||||
final dbFile = File(config.dbPath);
|
||||
if (!dbFile.existsSync()) {
|
||||
stderr.writeln('DB 파일을 찾을 수 없습니다: ${config.dbPath}');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
final sqlitePath = await _findSqliteBinary();
|
||||
if (sqlitePath == null) {
|
||||
stderr.writeln('sqlite3 바이너리를 찾을 수 없습니다. 시스템에 설치되어 있는지 확인하세요.');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
final rows = await _fetchRows(sqlitePath, dbFile.path);
|
||||
if (rows.isEmpty) {
|
||||
stderr.writeln('restaurants 테이블에서 가져온 행이 없습니다.');
|
||||
}
|
||||
|
||||
final seeds = rows.map(StoreSeedRow.fromMap).toList();
|
||||
final sourceBytes = await dbFile.readAsBytes();
|
||||
final sourceSignature = _buildSignature(sourceBytes);
|
||||
await _writeJson(
|
||||
config.dataOutputPath,
|
||||
seeds.map((e) => e.toJson()).toList(),
|
||||
);
|
||||
|
||||
final generatedAt = DateTime.now().toUtc().toIso8601String();
|
||||
final meta = StoreSeedMeta(
|
||||
version: config.version.isNotEmpty ? config.version : sourceSignature,
|
||||
generatedAt: generatedAt,
|
||||
sourceDb: dbFile.path,
|
||||
itemCount: seeds.length,
|
||||
sourceSignature: {
|
||||
'hash': sourceSignature,
|
||||
'size': sourceBytes.length,
|
||||
'modifiedMs': dbFile.lastModifiedSync().millisecondsSinceEpoch,
|
||||
},
|
||||
);
|
||||
await _writeJson(config.metaOutputPath, meta.toJson());
|
||||
|
||||
stdout.writeln(
|
||||
'변환 완료: ${seeds.length}개 항목 → '
|
||||
'${config.dataOutputPath} / ${config.metaOutputPath}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _findSqliteBinary() async {
|
||||
try {
|
||||
final result = await Process.run('which', ['sqlite3']);
|
||||
if (result.exitCode == 0) {
|
||||
final path = (result.stdout as String).trim();
|
||||
if (path.isNotEmpty) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchRows(
|
||||
String sqlitePath,
|
||||
String dbPath,
|
||||
) async {
|
||||
const query =
|
||||
'SELECT id, province, district, name, title, address, road_address, '
|
||||
'latitude, longitude FROM restaurants';
|
||||
|
||||
final result = await Process.run(
|
||||
sqlitePath,
|
||||
['-json', dbPath, query],
|
||||
stdoutEncoding: utf8,
|
||||
stderrEncoding: utf8,
|
||||
);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
stderr.writeln('sqlite3 실행 실패: ${result.stderr}');
|
||||
exit(result.exitCode);
|
||||
}
|
||||
|
||||
final output = result.stdout as String;
|
||||
final decoded = jsonDecode(output);
|
||||
if (decoded is! List) {
|
||||
stderr.writeln('예상치 못한 JSON 포맷입니다: ${decoded.runtimeType}');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> _writeJson(String path, Object data) async {
|
||||
final file = File(path);
|
||||
await file.parent.create(recursive: true);
|
||||
final encoder = const JsonEncoder.withIndent(' ');
|
||||
final content = encoder.convert(data);
|
||||
await file.writeAsString('$content\n');
|
||||
}
|
||||
|
||||
String _buildSignature(List<int> bytes) {
|
||||
int hash = 0;
|
||||
for (final byte in bytes) {
|
||||
hash = (hash * 31 + byte) & 0x7fffffff;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
Reference in New Issue
Block a user