feat: 초기 프로젝트 설정 및 LunchPick 앱 구현

LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다.

주요 기능:
- 네이버 지도 연동 맛집 추가
- 랜덤 메뉴 추천 시스템
- 날씨 기반 거리 조정
- 방문 기록 관리
- Bluetooth 맛집 공유
- 다크모드 지원

기술 스택:
- Flutter 3.8.1+
- Riverpod 상태 관리
- Hive 로컬 DB
- Clean Architecture

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-30 19:03:28 +09:00
commit 85fde36157
237 changed files with 30953 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_typography.dart';
import '../../providers/settings_provider.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
int _daysToExclude = 7;
int _notificationMinutes = 90;
bool _notificationEnabled = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final daysToExclude = await ref.read(daysToExcludeProvider.future);
final notificationMinutes = await ref.read(notificationDelayMinutesProvider.future);
final notificationEnabled = await ref.read(notificationEnabledProvider.future);
if (mounted) {
setState(() {
_daysToExclude = daysToExclude;
_notificationMinutes = notificationMinutes;
_notificationEnabled = notificationEnabled;
});
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground,
appBar: AppBar(
title: const Text('설정'),
backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary,
foregroundColor: Colors.white,
elevation: 0,
),
body: ListView(
children: [
// 추천 설정
_buildSection(
'추천 설정',
[
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: const Text('중복 방문 제외 기간'),
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: _daysToExclude > 1
? () async {
setState(() => _daysToExclude--);
await ref.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
}
: null,
color: AppColors.lightPrimary,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_daysToExclude일',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.lightPrimary,
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () async {
setState(() => _daysToExclude++);
await ref.read(settingsNotifierProvider.notifier)
.setDaysToExclude(_daysToExclude);
},
color: AppColors.lightPrimary,
),
],
),
),
),
],
isDark,
),
// 권한 설정
_buildSection(
'권한 관리',
[
FutureBuilder<PermissionStatus>(
future: Permission.location.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.location_on,
title: '위치 권한',
subtitle: '주변 맛집 거리 계산에 필요',
isGranted: isGranted,
onRequest: _requestLocationPermission,
isDark: isDark,
);
},
),
if (!kIsWeb)
FutureBuilder<PermissionStatus>(
future: Permission.bluetooth.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.bluetooth,
title: '블루투스 권한',
subtitle: '맛집 리스트 공유에 필요',
isGranted: isGranted,
onRequest: _requestBluetoothPermission,
isDark: isDark,
);
},
),
FutureBuilder<PermissionStatus>(
future: Permission.notification.status,
builder: (context, snapshot) {
final status = snapshot.data;
final isGranted = status?.isGranted ?? false;
return _buildPermissionTile(
icon: Icons.notifications,
title: '알림 권한',
subtitle: '방문 확인 알림에 필요',
isGranted: isGranted,
onRequest: _requestNotificationPermission,
isDark: isDark,
);
},
),
],
isDark,
),
// 알림 설정
_buildSection(
'알림 설정',
[
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: const Text('방문 확인 알림'),
subtitle: const Text('맛집 방문 후 확인 알림을 받습니다'),
value: _notificationEnabled,
onChanged: (value) async {
setState(() => _notificationEnabled = value);
await ref.read(settingsNotifierProvider.notifier)
.setNotificationEnabled(value);
},
activeColor: AppColors.lightPrimary,
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
enabled: _notificationEnabled,
title: const Text('방문 확인 알림 시간'),
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: _notificationEnabled && _notificationMinutes > 60
? () async {
setState(() => _notificationMinutes -= 30);
await ref.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(_notificationMinutes);
}
: null,
color: AppColors.lightPrimary,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.lightPrimary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_notificationMinutes ~/ 60}시간 ${_notificationMinutes % 60}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _notificationEnabled ? AppColors.lightPrimary : Colors.grey,
),
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: _notificationEnabled && _notificationMinutes < 360
? () async {
setState(() => _notificationMinutes += 30);
await ref.read(settingsNotifierProvider.notifier)
.setNotificationDelayMinutes(_notificationMinutes);
}
: null,
color: AppColors.lightPrimary,
),
],
),
),
),
],
isDark,
),
// 테마 설정
_buildSection(
'테마',
[
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(
isDark ? Icons.dark_mode : Icons.light_mode,
color: AppColors.lightPrimary,
),
title: const Text('테마 설정'),
subtitle: Text(isDark ? '다크 모드' : '라이트 모드'),
trailing: Switch(
value: isDark,
onChanged: (value) {
if (value) {
AdaptiveTheme.of(context).setDark();
} else {
AdaptiveTheme.of(context).setLight();
}
},
activeColor: AppColors.lightPrimary,
),
),
),
],
isDark,
),
// 앱 정보
_buildSection(
'앱 정보',
[
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const ListTile(
leading: Icon(Icons.info_outline, color: AppColors.lightPrimary),
title: Text('버전'),
subtitle: Text('1.0.0'),
),
const Divider(height: 1),
const ListTile(
leading: Icon(Icons.person_outline, color: AppColors.lightPrimary),
title: Text('개발자'),
subtitle: Text('NatureBridgeAI'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.description_outlined, color: AppColors.lightPrimary),
title: const Text('오픈소스 라이센스'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => showLicensePage(
context: context,
applicationName: '오늘 뭐 먹Z?',
applicationVersion: '1.0.0',
applicationLegalese: '© 2025 NatureBridgeAI',
),
),
],
),
),
],
isDark,
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildSection(String title, List<Widget> children, bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: Text(
title,
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
fontWeight: FontWeight.w600,
),
),
),
...children,
],
);
}
Widget _buildPermissionTile({
required IconData icon,
required String title,
required String subtitle,
required bool isGranted,
required VoidCallback onRequest,
required bool isDark,
}) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Icon(icon, color: isGranted ? Colors.green : Colors.grey),
title: Text(title),
subtitle: Text(subtitle),
trailing: isGranted
? const Icon(Icons.check_circle, color: Colors.green)
: ElevatedButton(
onPressed: onRequest,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('허용'),
),
enabled: !isGranted,
),
);
}
Future<void> _requestLocationPermission() async {
final status = await Permission.location.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('위치');
}
}
Future<void> _requestBluetoothPermission() async {
final status = await Permission.bluetooth.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('블루투스');
}
}
Future<void> _requestNotificationPermission() async {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() {});
} else if (status.isPermanentlyDenied) {
_showPermissionDialog('알림');
}
}
void _showPermissionDialog(String permissionName) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
title: const Text('권한 설정 필요'),
content: Text('$permissionName 권한이 거부되었습니다. 설정에서 직접 권한을 허용해주세요.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
style: TextButton.styleFrom(
foregroundColor: AppColors.lightPrimary,
),
child: const Text('설정으로 이동'),
),
],
),
);
}
}