feat(app): seed restaurants, geocode addresses, refresh sharing

This commit is contained in:
JiWoong Sul
2025-11-26 19:01:00 +09:00
parent 2a01fa50c6
commit 0e8c06bade
29 changed files with 18319 additions and 427 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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
View 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

View File

@@ -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==

Binary file not shown.

View 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

View File

@@ -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);
}

View File

@@ -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;

View 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);
}
}

View File

@@ -95,6 +95,7 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
businessHours: restaurant.businessHours,
lastVisited: visitDate,
visitCount: restaurant.visitCount + 1,
needsAddressVerification: restaurant.needsAddressVerification,
);
await updateRestaurant(updatedRestaurant);
}

View File

@@ -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');
}

View File

@@ -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);

View 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;
}
}

View File

@@ -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,
}

View File

@@ -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) {

View File

@@ -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 }

View File

@@ -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()),

View File

@@ -223,7 +223,7 @@ class AddRestaurantForm extends StatelessWidget {
),
const SizedBox(height: 8),
Text(
'* 위도/경도를 입력하지 않으면 서울시청 기준으로 저장됩니다',
'주소가 정확하지 않을 경우 위도/경도를 현재 위치로 입력합니다.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),

View File

@@ -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,
);
}
}

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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();

View File

@@ -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();
});

View File

@@ -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());

View File

@@ -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);

View File

@@ -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

View File

@@ -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(

View File

@@ -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
View 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');
}