Merge remote-tracking branch 'origin/master'

This commit is contained in:
2025-12-10 11:23:06 +09:00
14 changed files with 1029 additions and 196 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

BIN
assets/images/metaq_v.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

BIN
assets/images/white_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
assets/images/white_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -38,11 +38,10 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
// 실제 명령 전송 // 실제 명령 전송
final success = await action(); final success = await action();
setState(() => _isLoading = false);
if (!mounted) return; if (!mounted) return;
}
setState(() => _isLoading = false);
}
// 25.12.03 지은 추가 끝 // 25.12.03 지은 추가 끝
void test() async { void test() async {
@@ -108,7 +107,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
Widget _buildOverviewSection() { Widget _buildOverviewSection() {
return Container( return Container(
margin: const EdgeInsets.only(top: 5), margin: const EdgeInsets.only(top: 5),
child: Card( child: DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
child: Column( child: Column(
@@ -170,7 +169,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
_selectedImageIndex == 0 _selectedImageIndex == 0
? 'assets/images/storage.png' ? 'assets/images/storage.png'
: 'assets/images/top.png', : 'assets/images/top.png',
width: 100, width: 90,
), ),
), ),
Positioned( Positioned(
@@ -261,7 +260,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
} }
Widget _buildBatteryStatusCard() { Widget _buildBatteryStatusCard() {
return Card( return DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
child: Padding( child: Padding(
@@ -342,7 +341,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
} }
Widget _buildControlCard() { Widget _buildControlCard() {
return Card( return DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
child: Padding( child: Padding(
@@ -482,7 +481,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
} }
Widget _buildEnvironmentSensorsCard() { Widget _buildEnvironmentSensorsCard() {
return Card( return DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
child: Padding( child: Padding(
@@ -552,7 +551,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
Widget _buildMyLocationCard() { Widget _buildMyLocationCard() {
const LatLng exampleLocation = LatLng(37.5665, 126.9780); const LatLng exampleLocation = LatLng(37.5665, 126.9780);
return Card( return DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -620,7 +619,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
} }
Widget _buildActivityCard() { Widget _buildActivityCard() {
return Card( return DashboardCard(
shadow: _cleanShadow, shadow: _cleanShadow,
cardColor: _cardBackgroundColor, cardColor: _cardBackgroundColor,
child: Padding( child: Padding(
@@ -647,9 +646,9 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_activityText('10:45 AM - 헬멧 잠금 해제'), _activityText('10:45 AM - 안전모 잠금 해제'),
const SizedBox(height: 8), const SizedBox(height: 8),
_activityText('11:00 AM - 헬멧 착용 해제'), _activityText('11:00 AM - 안전모 착용 해제'),
], ],
), ),
), ),
@@ -721,7 +720,7 @@ class _LineChartPainter extends CustomPainter {
} }
} }
class Card extends StatelessWidget { class DashboardCard extends StatelessWidget {
final Widget child; final Widget child;
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
final Clip clipBehavior; final Clip clipBehavior;
@@ -729,7 +728,7 @@ class Card extends StatelessWidget {
final Color? cardColor; final Color? cardColor;
final BoxShadow? shadow; final BoxShadow? shadow;
const Card({ const DashboardCard({
super.key, super.key,
required this.child, required this.child,
this.padding, this.padding,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'main.dart'; import 'main.dart';
import 'register_screen.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -15,7 +16,7 @@ class _LoginScreenState extends State<LoginScreen> {
final FocusNode _idFocusNode = FocusNode(); final FocusNode _idFocusNode = FocusNode();
final FocusNode _pwFocusNode = FocusNode(); final FocusNode _pwFocusNode = FocusNode();
final Color mainBlueColor = const Color(0xFF007AFF); final Color mainBlueColor = const Color(0xFF0033CC);
bool _isIdFocused = false; bool _isIdFocused = false;
bool _isPwFocused = false; bool _isPwFocused = false;
@@ -45,26 +46,21 @@ class _LoginScreenState extends State<LoginScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
body: SafeArea( body: SafeArea(
child: Center(
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon(Icons.polymer, size: 80, color: mainBlueColor), Image.asset(
const SizedBox(height: 20), 'assets/images/metaq_v.png',
Text( width: 86,
'METAQLAB', height: 86,
textAlign: TextAlign.center, fit: BoxFit.contain,
style: TextStyle(
color: mainBlueColor,
fontSize: 24,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
), ),
), const SizedBox(height: 70),
const SizedBox(height: 60),
_buildCustomTextField( _buildCustomTextField(
label: '아이디', label: '아이디',
controller: _idController, controller: _idController,
@@ -72,7 +68,6 @@ class _LoginScreenState extends State<LoginScreen> {
isFocused: _isIdFocused, isFocused: _isIdFocused,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCustomTextField( _buildCustomTextField(
label: '비밀번호', label: '비밀번호',
controller: _pwController, controller: _pwController,
@@ -80,22 +75,19 @@ class _LoginScreenState extends State<LoginScreen> {
isFocused: _isPwFocused, isFocused: _isPwFocused,
isObscure: true, isObscure: true,
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
String inputId = _idController.text; String inputId = _idController.text;
String inputPw = _pwController.text; String inputPw = _pwController.text;
if (inputId == 'user' && inputPw == '1234') { if (inputId == 'user' && inputPw == '1234') {
print('로그인 성공!');
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute(builder: (context) => const HomeScreen()), MaterialPageRoute(
builder: (context) => const HomeScreen()),
); );
} else { } else {
print('로그인 실패');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('아이디 또는 비밀번호가 틀렸습니다.'), content: Text('아이디 또는 비밀번호가 틀렸습니다.'),
@@ -116,22 +108,42 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
child: const Text( child: const Text(
'로그인', '로그인',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style:
TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
), ),
const SizedBox(height: 12),
const SizedBox(height: 20), ElevatedButton(
TextButton( onPressed: () {
onPressed: () {}, Navigator.push(
child: Text( context,
'비밀번호 찾기 / 회원가입', MaterialPageRoute(
style: TextStyle(color: mainBlueColor, fontWeight: FontWeight.w500), builder: (context) => const RegisterScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: mainBlueColor,
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: mainBlueColor, width: 1.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'회원가입',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
), ),
], ],
), ),
), ),
), ),
),
),
); );
} }
@@ -144,7 +156,7 @@ class _LoginScreenState extends State<LoginScreen> {
}) { }) {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border.all( border: Border.all(
@@ -153,23 +165,16 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: TextField(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: isFocused ? mainBlueColor : Colors.grey.shade600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
TextField(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
obscureText: isObscure, obscureText: isObscure,
decoration: const InputDecoration( decoration : InputDecoration(
hintText: label,
hintStyle: TextStyle(
color: Colors.grey.shade400,
fontSize: 16,
),
isDense: true, isDense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
border: InputBorder.none, border: InputBorder.none,
@@ -182,8 +187,6 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
cursorColor: mainBlueColor, cursorColor: mainBlueColor,
), ),
],
),
); );
} }
} }

View File

@@ -39,7 +39,7 @@ class SmartHelmetApp extends StatelessWidget {
bodyMedium: TextStyle(color: _subTextColor, fontWeight: FontWeight.w400), bodyMedium: TextStyle(color: _subTextColor, fontWeight: FontWeight.w400),
), ),
), ),
home: const HomeScreen(), home: const LoginScreen(),
); );
} }
} }

520
lib/register_screen.dart Normal file
View File

@@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const String _serviceTermContent =
'제1조 (목적)\n'
'본 약관은 (주)메타큐랩(이하 "회사")이 제공하는 공유 전동 킥보드용 스마트 안전모 보관함 및 관련 애플리케이션 서비스(이하 "서비스")의 이용과 관련하여 회사와 회원의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.\n\n'
'제2조 (용어의 정의)\n'
'1. "서비스"란 회사가 제공하는 스마트 안전모 보관함 앱을 통해 안전모를 대여, 반납, 관리하는 모든 제반 서비스를 의미합니다.\n'
'2. "보관함"이란 안전모의 보관, 살균, 건조, 잠금 기능을 수행하는 물리적 하드웨어 장치를 말합니다.\n'
'3. "회원"이란 앱을 설치하고 본 약관에 동의하여 회사와 이용계약을 체결한 자를 말합니다.\n'
'4. "대여"란 앱을 통해 보관함의 잠금을 해제하고 안전모를 수령하는 행위를 말합니다.\n'
'5. "반납"이란 사용 후 안전모를 보관함에 넣고 문을 닫아 센서가 안전모를 인식하고 잠금이 완료된 상태를 말합니다.\n\n'
'제3조 (이용계약의 체결)\n'
'1. 이용계약은 회원이 되고자 하는 자(이하 "가입신청자")가 약관의 내용에 대하여 동의를 한 다음 회원가입 신청을 하고 회사가 이러한 신청을 승낙함으로써 체결됩니다.\n'
'2. 가입신청자는 앱 내에서 아이디(이메일), 비밀번호 등 필수 정보를 입력해야 하며, 회사는 필요시 본인 인증을 요구할 수 있습니다.\n\n'
'제4조 (서비스의 제공 및 기능)\n'
'회사는 회원에게 다음과 같은 서비스를 제공합니다.\n'
'1. 안전모 보관함 위치 찾기 (지도 및 리스트 제공)\n'
'2. 보관함 잠금장치 원격 제어 (대여/반납)\n'
'3. 안전모 살균 및 건조 상태 모니터링\n'
'4. 이용 내역 조회 및 알림 서비스\n\n'
'제5조 (대여 및 반납)\n'
'1. 회원은 앱을 통해 이용 가능한 보관함을 확인하고 잠금을 해제하여 안전모를 대여할 수 있습니다.\n'
'2. 회원은 이용 종료 후 안전모를 지정된 보관함에 넣고 문을 닫아야 합니다.\n'
'3. (반납의 완료) 반납 처리는 보관함 내부 센서가 안전모를 정상적으로 인식하고 문이 잠길 때 완료됩니다. 안전모가 감지되지 않거나 도어가 닫히지 않을 경우 반납으로 처리되지 않으며, 이에 따른 불이익은 회원이 부담합니다.\n'
'4. 센서 오류 등으로 반납 처리가 되지 않을 경우, 회원은 앱 내 "비상 신고 버튼" 또는 사진 업로드 기능을 통해 반납 사실을 증명해야 합니다.\n\n'
'제6조 (이용 요금 및 결제)\n'
'1. 서비스 이용 요금은 회사의 정책에 따르며 앱 내에 공지합니다.\n'
'2. 회원이 안전모를 분실하거나 파손한 경우, 회사는 별도의 실비 변상을 청구할 수 있습니다.\n\n'
'제7조 (살균 및 건조)\n'
'보관함은 반납 완료 후 자동으로 UV 살균 및 건조 기능을 수행합니다. 단, 회원은 착용 전 안전모의 상태를 육안으로 확인해야 하며, 오염 등이 심한 경우 이용을 중단하고 신고해야 합니다.\n\n'
'제8조 (회원의 의무)\n'
'1. 회원은 도로교통법 등 관련 법령에 따라 전동 킥보드 탑승 시 반드시 안전모를 착용해야 합니다.\n'
'2. 회원은 대여한 안전모를 제3자에게 양도하거나 대여해서는 안 됩니다.\n'
'3. 회원은 보관함 및 안전모를 파손하거나 기능을 임의로 조작해서는 안 됩니다.\n\n'
'제9조 (위치기반서비스의 내용)\n'
'회사는 회원의 위치 정보를 이용하여 주변 보관함 검색 기능을 제공하며, 회원의 위치 정보는 서비스 제공 목적 외에는 사용되지 않습니다.\n\n'
'제10조 (책임 제한)\n'
'1. 회사는 천재지변 또는 이에 준하는 불가항력으로 인하여 서비스를 제공할 수 없는 경우에는 서비스 제공에 관한 책임이 면제됩니다.\n'
'2. 회사는 회원의 귀책사유로 인한 서비스 이용의 장애에 대하여는 책임을 지지 않습니다.\n'
'3. 회원은 안전모 미착용 또는 올바르지 않은 착용으로 인해 발생한 사고에 대해 전적으로 책임을 지며, 회사는 이에 대해 책임을 지지 않습니다.\n\n'
'제11조 (서비스 알림)\n'
'회사는 회원의 안전한 이용을 위해 대여/반납 현황, 미반납 알림, 이상 감지 알림 등을 PUSH 알림으로 전송할 수 있습니다.\n\n'
'제12조 (준거법 및 재판관할)\n'
'본 약관에 명시되지 않은 사항은 대한민국의 관계 법령에 따르며, 서비스 이용으로 발생한 분쟁에 대해 소송이 제기되는 경우 회사의 본점 소재지를 관할하는 법원을 전속 관할법원으로 합니다.\n\n'
'부 칙\n'
'본 약관은 2025년 11월 28일부터 시행합니다.\n';
const String _privacyTermContent =
'(주)메타큐랩은 서비스 제공을 위하여 아래와 같이 귀하의 개인정보를 수집·이용합니다. 내용을 자세히 읽으신 후 동의해 주시기 바랍니다.\n\n'
'1. 수집 및 이용 목적\n'
'- 회원 가입 및 관리: 본인 확인, 개인 식별, 불량 회원의 부정이용 방지, 가입 의사 확인.\n'
'- 서비스 제공: 스마트 안전모 보관함 대여/반납 처리, 기기 제어, 이용 내역 관리, 살균/건조 상태 정보 제공.\n'
'- 고객 지원: 공지사항 전달, 서비스 장애 및 오류 신고 접수, 민원 처리.\n\n'
'2. 수집하는 개인정보 항목\n'
'- (필수) 아이디(이메일), 비밀번호, 이름, 휴대전화번호.\n'
'- (자동 수집) 기기 식별 정보(Device ID), PUSH 토큰, 서비스 이용 기록, 접속 로그.\n\n'
'3. 보유 및 이용 기간\n'
'회원 탈퇴 시까지 (단, 관계 법령에 따라 일정 기간 보존이 필요한 경우 해당 기간까지 보관)\n\n'
'4. 동의 거부 권리 및 불이익\n'
'귀하는 개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있습니다. 단, 필수 항목에 대한 동의를 거부할 경우 회원 가입 및 서비스 이용이 제한됩니다.\n';
const String _locationTermContent = """
1. 위치정보의 수집 및 이용 목적
회사는 이용자의 현재 위치를 확인하여 긴급 구조 요청, 주행 경로 기록, 주변 시설 검색 등 위치 기반 서비스를 제공하기 위해 위치정보를 수집 및 이용합니다.
2. 위치정보의 보유 및 이용 기간
회사는 위치정보의 수집 및 이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령의 규정에 의하여 보존할 필요가 있는 경우 법령에서 정한 기간 동안 보관합니다.
""";
const String _marketingTermContent =
'(주)메타큐랩이 제공하는 이벤트 및 혜택 등 마케팅 정보를 수신하는 것에 동의합니다.\n\n'
'1. 수집 및 이용 목적\n'
'- 신규 서비스(기능) 개발 및 맞춤 서비스 제공.\n'
'- 이벤트, 프로모션, 혜택 등 광고성 정보 제공.\n'
'- 서비스 이용 통계 및 설문조사.\n\n'
'2. 수집하는 개인정보 항목\n'
'- 이름, 휴대전화번호, 이메일, PUSH 토큰.\n\n'
'3. 보유 및 이용 기간\n'
'- 회원 탈퇴 또는 동의 철회 시까지\n\n'
'4. 전송 방법\n'
'- 앱 푸시(App Push) 알림, SMS(문자메시지), 이메일 등.\n\n'
'5. 동의 거부 권리\n'
'귀하는 마케팅 정보 수신에 대한 동의를 거부할 수 있습니다. 동의를 거부하더라도 회원 가입 및 기본 서비스 이용에는 제한이 없습니다. 다만, 이벤트 및 혜택 정보 제공이 제한될 수 있습니다.\n';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final TextEditingController _idController = TextEditingController();
final TextEditingController _pwController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _nicknameController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
bool _isServiceAgreed = false;
bool _isPrivacyAgreed = false;
bool _isLocationAgreed = false;
bool _isMarketingAgreed = false;
final Color mainBlueColor = const Color(0xFF0033CC);
final Color backgroundColor = const Color(0xFFF5F6F8);
final Color iconColor = const Color(0xFF8899A6);
@override
void dispose() {
_idController.dispose();
_pwController.dispose();
_nameController.dispose();
_nicknameController.dispose();
_phoneController.dispose();
_emailController.dispose();
super.dispose();
}
void _tryRegister() {
if (_idController.text.isEmpty) return _showError('아이디를 입력해주세요.');
if (_pwController.text.isEmpty) return _showError('비밀번호를 입력해주세요.');
if (_emailController.text.isEmpty) return _showError('이메일을 입력해주세요.');
if (_nameController.text.isEmpty) return _showError('이름을 입력해주세요.');
if (_phoneController.text.isEmpty) return _showError('전화번호를 입력해주세요.');
if (_phoneController.text.length < 12) return _showError('올바른 전화번호를 입력해주세요.');
if (_nicknameController.text.isEmpty) return _showError('닉네임을 입력해주세요.');
if (!_isServiceAgreed || !_isPrivacyAgreed || !_isLocationAgreed) {
return _showError('(필수) 약관에 모두 동의해주세요.');
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('회원가입이 완료되었습니다! 환영합니다.'),
backgroundColor: mainBlueColor,
behavior: SnackBarBehavior.floating,
),
);
Future.delayed(const Duration(seconds: 1), () {
if (mounted) Navigator.pop(context);
});
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.redAccent,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
void _showTermDetails(String title, String content) {
String fullContent = content;
showDialog(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 10, top: 15, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.grey),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1, thickness: 1),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Text(
fullContent,
style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.black54),
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: mainBlueColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: const Text(
'확인',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor,
appBar: AppBar(
backgroundColor: backgroundColor,
elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.black),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'회원가입',
style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold, fontSize: 18),
),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 30.0),
child: Column(
children: [
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
_buildInputRow(
icon: Icons.account_circle,
hint: '아이디',
controller: _idController,
),
_buildDivider(),
_buildInputRow(
icon: Icons.lock_outline,
hint: '비밀번호',
controller: _pwController,
isObscure: true,
),
_buildDivider(),
_buildInputRow(
icon: Icons.email_outlined,
hint: '이메일',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
),
],
),
),
const SizedBox(height: 20),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
_buildInputRow(
icon: Icons.account_circle,
hint: '이름',
controller: _nameController,
),
_buildDivider(),
_buildInputRow(
icon: Icons.phone_iphone_outlined,
hint: '전화번호',
controller: _phoneController,
keyboardType: TextInputType.phone,
isPhone: true,
),
_buildDivider(),
_buildInputRow(
icon: Icons.person_outline,
hint: '닉네임',
controller: _nicknameController,
),
],
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'약관 동의',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildAgreementRow(
title: '(필수) 서비스 이용약관 동의',
value: _isServiceAgreed,
onChanged: (v) => setState(() => _isServiceAgreed = v!),
details: _serviceTermContent,
),
const SizedBox(height: 12),
_buildAgreementRow(
title: '(필수) 개인정보 수집 및 이용 동의',
value: _isPrivacyAgreed,
onChanged: (v) => setState(() => _isPrivacyAgreed = v!),
details: _privacyTermContent,
),
const SizedBox(height: 12),
_buildAgreementRow(
title: '(필수) 위치기반 서비스 이용약관 동의',
value: _isLocationAgreed,
onChanged: (v) => setState(() => _isLocationAgreed = v!),
details: _locationTermContent,
),
const SizedBox(height: 12),
_buildAgreementRow(
title: '(선택) 이벤트 및 마케팅 수신 동의',
value: _isMarketingAgreed,
onChanged: (v) => setState(() => _isMarketingAgreed = v!),
details: _marketingTermContent,
),
],
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _tryRegister,
style: ElevatedButton.styleFrom(
backgroundColor: mainBlueColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
minimumSize: const Size(double.infinity, 56),
),
child: const Text(
'가입하기',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 30),
],
),
),
),
);
}
Widget _buildDivider() {
return Divider(height: 1, thickness: 1, color: Colors.grey.shade100, indent: 50, endIndent: 20);
}
Widget _buildInputRow({
required IconData icon,
required String hint,
required TextEditingController controller,
bool isObscure = false,
TextInputType keyboardType = TextInputType.text,
bool isPhone = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Icon(icon, color: iconColor, size: 24),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller,
obscureText: isObscure,
keyboardType: keyboardType,
inputFormatters: isPhone
? [
FilteringTextInputFormatter.digitsOnly,
_PhoneNumberFormatter(),
LengthLimitingTextInputFormatter(13),
]
: [],
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 15),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
style: const TextStyle(fontSize: 16, color: Colors.black87),
cursorColor: mainBlueColor,
),
),
],
),
);
}
Widget _buildAgreementRow({
required String title,
required bool value,
required ValueChanged<bool?> onChanged,
required String details,
}) {
return Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: value,
activeColor: mainBlueColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
side: BorderSide(color: Colors.grey.shade300, width: 1.5),
onChanged: onChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => onChanged(!value),
child: Text(
title,
style: const TextStyle(fontSize: 14, color: Colors.black87),
),
),
),
IconButton(
onPressed: () => _showTermDetails(title, details),
icon: const Icon(Icons.arrow_forward_ios_rounded, color: Colors.grey, size: 14),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
);
}
}
class _PhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
var text = newValue.text;
if (newValue.selection.baseOffset == 0) return newValue;
var buffer = StringBuffer();
for (int i = 0; i < text.length; i++) {
buffer.write(text[i]);
var nonZeroIndex = i + 1;
if (nonZeroIndex <= 3) {
if (nonZeroIndex % 3 == 0 && nonZeroIndex != text.length) buffer.write('-');
} else {
if (nonZeroIndex % 7 == 0 && nonZeroIndex != text.length && nonZeroIndex > 4) buffer.write('-');
}
}
var string = buffer.toString();
return newValue.copyWith(text: string, selection: TextSelection.collapsed(offset: string.length));
}
}

View File

@@ -368,7 +368,7 @@ class RentReturnScreen extends StatelessWidget {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
children: [ children: [
_buildLogItem("08:58:33", "문 닫힘", Icons.door_front_door, _mainBlueColor), _buildLogItem("08:58:33", "문 닫힘", Icons.door_front_door, _mainBlueColor),
_buildLogItem("08:58:30", "헬멧 반납 확인", Icons.check_circle_outline, _mainBlueColor), _buildLogItem("08:58:30", "안전모 반납 확인", Icons.check_circle_outline, _mainBlueColor),
_buildLogItem("08:55:12", "사용자 문 잠금 해제", Icons.lock_open, _mainTextColor), _buildLogItem("08:55:12", "사용자 문 잠금 해제", Icons.lock_open, _mainTextColor),
_buildLogItem("08:30:00", "UV 살균 완료", Icons.cleaning_services, _mainBlueColor), _buildLogItem("08:30:00", "UV 살균 완료", Icons.cleaning_services, _mainBlueColor),
_buildLogItem("08:00:00", "시스템 가동 중", Icons.power_settings_new, _subTextColor), _buildLogItem("08:00:00", "시스템 가동 중", Icons.power_settings_new, _subTextColor),

View File

@@ -53,8 +53,8 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
Timer? _timer; Timer? _timer;
final List<String> _imageList = [ final List<String> _imageList = [
'assets/images/top.png', 'assets/images/top.png',
'assets/images/info1.png', 'assets/images/white_1.png',
'assets/images/info2.png', 'assets/images/white_2.png',
]; ];
@override @override
@@ -112,7 +112,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
children: [ children: [
_buildStatusCard(context), _buildStatusCard(context),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildProcessSectionTitle('헬멧 대여'), _buildProcessSectionTitle('안전모 대여'),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -126,7 +126,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
_buildDivider(), _buildDivider(),
_buildStepRow(2, '문 상태 확인', Icons.sensor_door_outlined), _buildStepRow(2, '문 상태 확인', Icons.sensor_door_outlined),
_buildDivider(), _buildDivider(),
_buildStepRow(3, '헬멧 꺼내기', Icons.outbox), _buildStepRow(3, '안전모 꺼내기', Icons.outbox),
_buildDivider(), _buildDivider(),
_buildStepRow(4, '주행 시작', Icons.sentiment_satisfied_alt, _buildStepRow(4, '주행 시작', Icons.sentiment_satisfied_alt,
showDivider: false), showDivider: false),
@@ -134,7 +134,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildProcessSectionTitle('헬멧 반납'), _buildProcessSectionTitle('안전모 반납'),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -145,7 +145,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
children: [ children: [
_buildStepRow(1, '잠금해제 & 문열림', Icons.lock_open), _buildStepRow(1, '잠금해제 & 문열림', Icons.lock_open),
_buildDivider(), _buildDivider(),
_buildStepRow(2, '헬멧 넣기', Icons.move_to_inbox), _buildStepRow(2, '안전모 넣기', Icons.move_to_inbox),
_buildDivider(), _buildDivider(),
_buildStepRow(3, '센서 스캔 & 반납 완료', Icons.sync), _buildStepRow(3, '센서 스캔 & 반납 완료', Icons.sync),
_buildDivider(), _buildDivider(),
@@ -477,7 +477,7 @@ Future<void> _runLockerAction(String name, Future<bool> Function() action) async
children: [ children: [
_buildLogItem("08:58:33", "문 열림", Icons.door_front_door, _buildLogItem("08:58:33", "문 열림", Icons.door_front_door,
_mainBlueColor), _mainBlueColor),
_buildLogItem("08:58:30", "헬멧 반납 확인(센서 A)", _buildLogItem("08:58:30", "안전모 반납 확인(센서 A)",
Icons.check_circle_outline, _mainBlueColor), Icons.check_circle_outline, _mainBlueColor),
_buildLogItem("08:55:12", "사용자 문 잠금 해제", Icons.lock_open, _buildLogItem("08:55:12", "사용자 문 잠금 해제", Icons.lock_open,
_mainTextColor), _mainTextColor),

View File

@@ -15,6 +15,172 @@ const BoxShadow _cleanShadow = BoxShadow(
spreadRadius: 0, spreadRadius: 0,
); );
const String _serviceTermContent =
'제1조 (목적)\n'
'본 약관은 (주)메타큐랩(이하 "회사")이 제공하는 공유 전동 킥보드용 스마트 안전모 보관함 및 관련 애플리케이션 서비스(이하 "서비스")의 이용과 관련하여 회사와 회원의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.\n\n'
'제2조 (용어의 정의)\n'
'1. "서비스"란 회사가 제공하는 스마트 안전모 보관함 앱을 통해 안전모를 대여, 반납, 관리하는 모든 제반 서비스를 의미합니다.\n'
'2. "보관함"이란 안전모의 보관, 살균, 건조, 잠금 기능을 수행하는 물리적 하드웨어 장치를 말합니다.\n'
'3. "회원"이란 앱을 설치하고 본 약관에 동의하여 회사와 이용계약을 체결한 자를 말합니다.\n'
'4. "대여"란 앱을 통해 보관함의 잠금을 해제하고 안전모를 수령하는 행위를 말합니다.\n'
'5. "반납"이란 사용 후 안전모를 보관함에 넣고 문을 닫아 센서가 안전모를 인식하고 잠금이 완료된 상태를 말합니다.\n\n'
'제3조 (이용계약의 체결)\n'
'1. 이용계약은 회원이 되고자 하는 자(이하 "가입신청자")가 약관의 내용에 대하여 동의를 한 다음 회원가입 신청을 하고 회사가 이러한 신청을 승낙함으로써 체결됩니다.\n'
'2. 가입신청자는 앱 내에서 아이디(이메일), 비밀번호 등 필수 정보를 입력해야 하며, 회사는 필요시 본인 인증을 요구할 수 있습니다.\n\n'
'제4조 (서비스의 제공 및 기능)\n'
'회사는 회원에게 다음과 같은 서비스를 제공합니다.\n'
'1. 안전모 보관함 위치 찾기 (지도 및 리스트 제공)\n'
'2. 보관함 잠금장치 원격 제어 (대여/반납)\n'
'3. 안전모 살균 및 건조 상태 모니터링\n'
'4. 이용 내역 조회 및 알림 서비스\n\n'
'제5조 (대여 및 반납)\n'
'1. 회원은 앱을 통해 이용 가능한 보관함을 확인하고 잠금을 해제하여 안전모를 대여할 수 있습니다.\n'
'2. 회원은 이용 종료 후 안전모를 지정된 보관함에 넣고 문을 닫아야 합니다.\n'
'3. (반납의 완료) 반납 처리는 보관함 내부 센서가 안전모를 정상적으로 인식하고 문이 잠길 때 완료됩니다. 안전모가 감지되지 않거나 도어가 닫히지 않을 경우 반납으로 처리되지 않으며, 이에 따른 불이익은 회원이 부담합니다.\n'
'4. 센서 오류 등으로 반납 처리가 되지 않을 경우, 회원은 앱 내 "비상 신고 버튼" 또는 사진 업로드 기능을 통해 반납 사실을 증명해야 합니다.\n\n'
'제6조 (이용 요금 및 결제)\n'
'1. 서비스 이용 요금은 회사의 정책에 따르며 앱 내에 공지합니다.\n'
'2. 회원이 안전모를 분실하거나 파손한 경우, 회사는 별도의 실비 변상을 청구할 수 있습니다.\n\n'
'제7조 (살균 및 건조)\n'
'보관함은 반납 완료 후 자동으로 UV 살균 및 건조 기능을 수행합니다. 단, 회원은 착용 전 안전모의 상태를 육안으로 확인해야 하며, 오염 등이 심한 경우 이용을 중단하고 신고해야 합니다.\n\n'
'제8조 (회원의 의무)\n'
'1. 회원은 도로교통법 등 관련 법령에 따라 전동 킥보드 탑승 시 반드시 안전모를 착용해야 합니다.\n'
'2. 회원은 대여한 안전모를 제3자에게 양도하거나 대여해서는 안 됩니다.\n'
'3. 회원은 보관함 및 안전모를 파손하거나 기능을 임의로 조작해서는 안 됩니다.\n\n'
'제9조 (위치기반서비스의 내용)\n'
'회사는 회원의 위치 정보를 이용하여 주변 보관함 검색 기능을 제공하며, 회원의 위치 정보는 서비스 제공 목적 외에는 사용되지 않습니다.\n\n'
'제10조 (책임 제한)\n'
'1. 회사는 천재지변 또는 이에 준하는 불가항력으로 인하여 서비스를 제공할 수 없는 경우에는 서비스 제공에 관한 책임이 면제됩니다.\n'
'2. 회사는 회원의 귀책사유로 인한 서비스 이용의 장애에 대하여는 책임을 지지 않습니다.\n'
'3. 회원은 안전모 미착용 또는 올바르지 않은 착용으로 인해 발생한 사고에 대해 전적으로 책임을 지며, 회사는 이에 대해 책임을 지지 않습니다.\n\n'
'제11조 (서비스 알림)\n'
'회사는 회원의 안전한 이용을 위해 대여/반납 현황, 미반납 알림, 이상 감지 알림 등을 PUSH 알림으로 전송할 수 있습니다.\n\n'
'제12조 (준거법 및 재판관할)\n'
'본 약관에 명시되지 않은 사항은 대한민국의 관계 법령에 따르며, 서비스 이용으로 발생한 분쟁에 대해 소송이 제기되는 경우 회사의 본점 소재지를 관할하는 법원을 전속 관할법원으로 합니다.\n\n'
'부 칙\n'
'본 약관은 2025년 11월 28일부터 시행합니다.\n';
const String _privacyTermContent =
'(주)메타큐랩(이하 "회사")은 정보통신망 이용촉진 및 정보보호 등에 관한 법률, 개인정보보호법 등 관련 법령을 준수하며, 이용자의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다.\n\n'
'1. 개인정보의 수집 및 이용 목적\n'
'회사는 다음의 목적을 위하여 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\n'
'가. 회원 가입 및 관리\n'
'- 회원제 서비스 제공에 따른 본인 식별·인증\n'
'- 회원 자격 유지·관리, 서비스 부정이용 방지\n'
'- 만 14세 미만 아동의 개인정보 처리 시 법정대리인의 동의 여부 확인\n'
'- 각종 고지·통지, 고충 처리\n'
'나. 재화 또는 서비스 제공\n'
'- 안전모 보관함 위치 찾기: 지도 기반의 보관함 위치 정보 제공\n'
'- 보관함 대여 및 반납: 잠금장치 제어, 대여/반납 이력 관리\n'
'- 안전 관리: 안전모 착용 여부 확인, 살균/건조 상태 안내\n'
'- 장애 대응: 보관함 고장 신고 시 위치 및 현장 사진 접수\n'
'다. 신규 서비스 개발 및 마케팅·광고에의 활용\n'
'- 신규 서비스 개발 및 맞춤 서비스 제공\n'
'- 이벤트 및 광고성 정보 제공 및 참여기회 제공 (마케팅 동의 시)\n'
'- 서비스 유효성 확인, 접속 빈도 파악 또는 회원의 서비스 이용에 대한 통계\n\n'
'2. 수집하는 개인정보의 항목 및 수집 방법\n'
'회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집하고 있습니다.\n'
'가. 필수적 수집 항목\n'
'- 로그인 정보: 아이디(이메일), 비밀번호, 이름, 휴대전화번호\n'
'- 기기 정보: 기기 식별 고유번호(Device ID), PUSH 토큰(알림 수신용)\n'
'나. 서비스 이용 과정에서 생성/수집되는 항목\n'
'- 서비스 이용 기록(대여/반납 일시, 이용 보관함 정보), 접속 로그, 쿠키, 접속 IP 정보, 불량 이용 기록\n'
'- 위치 정보: 이용자의 현재 위치 (주변 보관함 검색 목적, 서버에 저장되지 않음)\n'
'다. 앱 접근 권한을 통한 수집 항목\n'
'- 카메라: 반납 인증 사진 촬영, 고장/오류 신고 시 사진 첨부\n'
'- 블루투스: 보관함 잠금장치 무선 연결 및 제어\n'
'- 위치: 지도상 현재 위치 표시 및 주변 기기 검색\n\n'
'3. 개인정보의 처리 및 보유 기간\n'
'회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.\n'
'- 회원 가입 및 관리 정보: 회원 탈퇴 시까지 (단, 관계 법령 위반에 따른 수사·조사 등이 진행 중인 경우에는 해당 수사·조사 종료 시까지)\n'
'- 재화 또는 서비스 제공: 재화·서비스 공급완료 및 요금결제·정산 완료 시까지\n'
'- 관련 법령에 의한 정보 보유:\n'
' 1) 계약 또는 청약철회 등에 관한 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)\n'
' 2) 대금결제 및 재화 등의 공급에 관한 기록: 5년\n'
' 3) 소비자의 불만 또는 분쟁처리에 관한 기록: 3년\n'
' 4) 웹사이트 방문 기록: 3개월 (통신비밀보호법)\n\n'
'4. 개인정보의 파기 절차 및 방법\n'
'회사는 개인정보 보유기간의 경과, 처리목적 달성 등 개인정보가 불필요하게 되었을 때에는 지체 없이 해당 개인정보를 파기합니다.\n'
'- 파기절차: 파기 사유가 발생한 개인정보를 선정하고, 회사의 개인정보 보호책임자의 승인을 받아 파기합니다.\n'
'- 파기방법: 전자적 파일 형태로 기록·저장된 개인정보는 기록을 재생할 수 없도록 파기하며, 종이 문서에 기록·저장된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.\n\n'
'5. 이용자와 법정대리인의 권리·의무 및 행사방법\n'
'- 이용자는 회사에 대해 언제든지 개인정보 열람·정정·삭제·처리정지 요구 등의 권리를 행사할 수 있습니다. (앱 내 "내 정보 수정" 또는 "회원 탈퇴" 메뉴 이용)\n'
'- 권리 행사는 회사에 대해 서면, 전화, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 회사는 이에 대해 지체 없이 조치하겠습니다.\n\n'
'6. 개인정보의 안전성 확보조치\n'
'회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다.\n'
'- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육\n'
'- 기술적 조치: 개인정보처리시스템 등의 접근권한 관리, 비밀번호 등 중요 정보의 암호화 저장, 보안프로그램 설치 및 주기적 점검\n'
'- 물리적 조치: 전산실, 자료보관실 등의 접근통제\n\n'
'7. 개인정보 보호책임자\n'
'회사는 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.\n'
'- 성명: 임지은\n'
'- 직책: 대표이사\n'
'- 연락처: 070-4272-9322, ljieun9005@gmail.com\n\n'
'8. 개인정보 처리방침의 변경 및 고지 의무\n'
'회사는 개인정보 처리방침의 법령, 정책 또는 보안기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시에는 개정 최소 7일 전부터 애플리케이션 내 "공지사항", 팝업, 또는 앱 PUSH 알림 등을 통하여 변경 사유 및 내용을 공지하도록 하겠습니다.\n'
'다만, 수집하는 개인정보의 항목, 이용목적의 변경 등과 같이 이용자의 권리에 중대한 변경이 발생할 때에는 최소 30일 전에 미리 공지하며, 필요 시 이용자의 동의를 다시 받을 수 있습니다.\n\n'
'부 칙\n'
'개인정보 처리방침은 2025년 11월 28일부터 시행합니다.\n';
const String _locationTermContent =
'[위치기반서비스 이용약관]\n\n'
'제 1 조 (목적)\n'
'본 약관은 (주)메타큐랩(이하 "회사")이 제공하는 스마트 안전모 보관함 애플리케이션 서비스(이하 "서비스")와 관련하여 회사와 개인위치정보주체(이하 "회원") 간의 권리, 의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.\n\n'
'제 2 조 (약관의 효력 및 변경)\n'
'1. 본 약관은 서비스를 신청한 고객 또는 개인위치정보주체가 본 약관에 동의하고 회사가 소정의 절차에 따라 서비스의 이용자로 등록함으로써 효력이 발생합니다.\n'
'2. 회사는 법률이나 위치기반서비스의 변경사항을 반영하기 위해 본 약관을 수정할 수 있으며, 약관을 개정할 경우에는 적용일자 7일 전부터 웹사이트 또는 애플리케이션 공지사항을 통해 공지합니다. 다만, 회원에게 불리한 내용으로 변경할 경우에는 최소 30일 전에 공지합니다.\n\n'
'제 3 조 (서비스의 내용)\n'
'회사는 위치정보사업자로부터 위치정보를 전달받아 아래와 같은 위치기반서비스를 제공합니다.\n'
'- 내 주변 보관함 찾기: 회원의 현재 위치를 기반으로 주변에 있는 스마트 안전모 보관함의 위치를 지도에 표시하고, 거리순으로 정렬하여 제공합니다.\n'
'- 이용 내역 및 경로 관리: 안전모 대여 시점과 반납 시점의 위치 정보를 기록하여 이용 내역을 관리하고, 비정상적인 반납(지정 구역 외 반납 등)을 확인합니다.\n'
'- 긴급 구조 및 사고/장애 신고: 보관함 이용 중 사고 또는 장애 발생 시, 신고 접수 단계에서 회원의 현재 위치를 확인하여 신속한 유지보수 및 대응을 지원합니다.\n\n'
'제 4 조 (서비스 이용요금)\n'
'회사가 제공하는 위치기반서비스는 무료입니다. 단, 무선 서비스 이용 시 발생하는 데이터 통신료는 별도이며, 이는 회원이 가입한 이동통신사의 정책에 따릅니다.\n\n'
'제 5 조 (개인위치정보주체의 권리)\n'
'1. 회원은 언제든지 개인위치정보의 수집, 이용 또는 제공에 대한 동의의 전부 또는 일부를 철회할 수 있습니다. 이 경우 회사는 수집한 개인위치정보 및 위치정보 이용·제공사실 확인자료를 파기합니다.\n'
'2. 회원은 언제든지 개인위치정보의 수집, 이용 또는 제공의 일시적인 중지를 요구할 수 있으며, 회사는 이를 거절할 수 없습니다.\n'
'3. 회원은 회사에 대하여 다음 각 호의 자료에 대한 열람 또는 고지를 요구할 수 있고, 당해 자료에 오류가 있는 경우에는 그 정정을 요구할 수 있습니다.\n'
'- 본인에 대한 위치정보 수집, 이용, 제공사실 확인자료\n'
'- 본인의 개인위치정보가 위치정보의 보호 및 이용 등에 관한 법률 또는 다른 법률 규정에 의하여 제3자에게 제공된 이유 및 내용\n\n'
'제 6 조 (위치정보 이용·제공사실 확인자료의 보유근거 및 보유기간)\n'
'회사는 위치정보의 보호 및 이용 등에 관한 법률 제16조 제2항에 근거하여 회원의 위치정보 수집, 이용, 제공사실 확인자료를 위치정보시스템에 자동으로 기록하며, 해당 자료는 6개월 이상 보관합니다.\n\n'
'제 7 조 (서비스의 변경 및 중지)\n'
'1. 회사는 위치정보사업자의 정책 변경 또는 기술적 사정으로 인하여 서비스의 전부 또는 일부를 제한, 변경하거나 중지할 수 있습니다.\n'
'2. 위와 같은 경우 회사는 그 사유를 사전에 공지하거나 통지합니다.\n\n'
'제 8 조 (개인위치정보의 제3자 제공)\n'
'회사는 회원의 동의 없이 개인위치정보를 제3자에게 제공하지 않으며, 제3자에게 제공하는 경우에는 제공받는 자, 제공일시 및 제공목적을 즉시 회원에게 통보합니다.\n'
'단, 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우는 예외로 합니다.\n\n'
'제 9 조 (손해배상)\n'
'회사가 위치정보의 보호 및 이용 등에 관한 법률 제15조 내지 제26조의 규정을 위반하여 회원에게 손해가 발생한 경우, 회원이 고의 또는 과실 없음을 입증하지 아니하면 회원은 그 손해에 대해 배상을 청구할 수 있습니다.\n\n'
'제 10 조 (분쟁의 조정)\n'
'1. 회사는 위치정보와 관련된 분쟁에 대해 당사자 간 협의가 이루어지지 아니하거나 협의를 할 수 없는 경우에는 방송통신위원회에 재정을 신청할 수 있습니다.\n'
'2. 회사 또는 고객은 위치정보와 관련된 분쟁에 대해 당사자 간 협의가 이루어지지 아니하거나 협의를 할 수 없는 경우에는 개인정보분쟁조정위원회에 조정을 신청할 수 있습니다.\n\n'
'제 11 조 (사업자 정보 및 위치정보 관리책임자)\n'
'1. 회사의 상호 및 주소 등은 다음과 같습니다.\n'
'- 상호: 주식회사 메타큐랩\n'
'- 주소: 전라남도 나주시 빛가람로 760, 103호\n'
'- 대표전화: 070-4272-9322\n'
'2. 회사는 개인위치정보를 적절히 관리·보호하고 개인위치정보주체의 불만을 원활히 처리할 수 있도록 실질적인 책임을 질 수 있는 지위에 있는 자를 위치정보 관리책임자로 지정해 운영합니다.\n'
'- 위치정보 관리책임자: 임지은\n'
'- 연락처: ljieun9005@gmail.com\n\n'
'부칙 제1조 (시행일)\n'
'본 약관은 2025년 11월 28일부터 시행합니다.\n';
const String _marketingTermContent =
'(주)메타큐랩이 제공하는 이벤트 및 혜택 등 마케팅 정보를 수신하는 것에 동의합니다.\n\n'
'1. 수집 및 이용 목적\n'
'- 신규 서비스(기능) 개발 및 맞춤 서비스 제공.\n'
'- 이벤트, 프로모션, 혜택 등 광고성 정보 제공.\n'
'- 서비스 이용 통계 및 설문조사.\n\n'
'2. 수집하는 개인정보 항목\n'
'- 이름, 휴대전화번호, 이메일, PUSH 토큰.\n\n'
'3. 보유 및 이용 기간\n'
'- 회원 탈퇴 또는 동의 철회 시까지\n\n'
'4. 전송 방법\n'
'- 앱 푸시(App Push) 알림, SMS(문자메시지), 이메일 등.\n\n'
'5. 동의 거부 권리\n'
'귀하는 마케팅 정보 수신에 대한 동의를 거부할 수 있습니다. 동의를 거부하더라도 회원 가입 및 기본 서비스 이용에는 제한이 없습니다. 다만, 이벤트 및 혜택 정보 제공이 제한될 수 있습니다.\n';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -27,6 +193,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isRentalAlertEnabled = true; bool _isRentalAlertEnabled = true;
bool _isStorageStatusAlert = true; bool _isStorageStatusAlert = true;
bool _isEnvSensorAlert = true; bool _isEnvSensorAlert = true;
bool _isMarketingAlertEnabled = true;
bool _isBiometricEnabled = false; bool _isBiometricEnabled = false;
bool _isAutoLogoutEnabled = true; bool _isAutoLogoutEnabled = true;
@@ -35,6 +202,120 @@ class _SettingsScreenState extends State<SettingsScreen> {
final String _currentAppVersion = "v1.0.2"; final String _currentAppVersion = "v1.0.2";
void _showDeleteAccountDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: _warningColor,
size: 64.0,
),
const SizedBox(height: 20),
Text(
'회원 탈퇴',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22.0,
fontWeight: FontWeight.bold,
color: _mainTextColor,
),
),
const SizedBox(height: 12),
Text(
'탈퇴 시 모든 이용 기록이 영구 삭제되며,\n 식제된 데이터는 복구할 수 없습니다.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.0,
color: _subTextColor,
height: 1.5,
),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'유지하기',
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("탈퇴 요청이 처리되었습니다.")),
);
},
style: FilledButton.styleFrom(
backgroundColor: _warningColor,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'탈퇴하기',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
),
);
},
);
}
void _showCommonModal(BuildContext context, String title, Widget content, {String rightButtonLabel = '닫기'}) { void _showCommonModal(BuildContext context, String title, Widget content, {String rightButtonLabel = '닫기'}) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -217,17 +498,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
void _showTermsSheet(BuildContext context) { void _showTermContentSheet(BuildContext context, String title, String content) {
_showCommonModal( _showCommonModal(
context, context,
'이용 약관', title,
SingleChildScrollView( SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: SizedBox(
width: double.infinity,
child: Text( child: Text(
'제 1 조 (목적)\n이 약관은 스마트 헬멧 서비스(이하 "서비스")의 이용 조건 및 절차, 이용자와 회사의 권리, 의무, 책임 사항을 규정함을 목적으로 합니다.\n\n제 2 조 (용어의 정의)\n1. "이용자"란 앱에 접속하여 본 약관에 따라 서비스를 이용하는 회원을 말합니다.\n2. "헬멧"이란 회사가 대여하는 스마트 IoT 안전모를 말합니다.\n\n(이하 생략... 더미 데이터입니다.)\n\n제 3 조 (약관의 효력)\n본 약관은 서비스를 신청한 때부터 효력이 발생합니다.', content,
textAlign: TextAlign.start,
style: TextStyle(color: _subTextColor, height: 1.6), style: TextStyle(color: _subTextColor, height: 1.6),
), ),
), ),
),
); );
} }
@@ -269,11 +554,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildInfoLink('전화 상담 연결', Icons.phone, value: '1588-0000'), _buildInfoLink('전화 상담 연결', Icons.phone, value: '070-4272-9322'),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildInfoLink('1:1 채팅 상담', Icons.chat_bubble_outline), _buildInfoLink('1:1 채팅 상담', Icons.chat_bubble_outline),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildInfoLink('이메일 문의', Icons.email_outlined, value: 'help@smarthelmet.com'), _buildInfoLink('이메일 문의', Icons.email_outlined, value: 'ljieun9005@gmail.com'),
], ],
), ),
); );
@@ -286,7 +571,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListView( ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildFAQItem('Q. 헬멧 대여는 어떻게 하나요?', 'A. 메인 화면의 지도에서 가까운 보관함을 찾은 후, QR코드를 스캔하여 대여할 수 있습니다.'), _buildFAQItem('Q. 안전모 대여는 어떻게 하나요?', 'A. 메인 화면의 지도에서 가까운 보관함을 찾은 후, QR코드를 스캔하여 대여할 수 있습니다.'),
_buildFAQItem('Q. 반납이 안 될 때는 어떻게 하나요?', 'A. 보관함의 통신 상태를 확인해 주세요. 지속적으로 실패할 경우 고객센터로 연락 바랍니다.'), _buildFAQItem('Q. 반납이 안 될 때는 어떻게 하나요?', 'A. 보관함의 통신 상태를 확인해 주세요. 지속적으로 실패할 경우 고객센터로 연락 바랍니다.'),
_buildFAQItem('Q. 결제 수단 변경은 어디서 하나요?', 'A. [마이페이지] > [결제 관리] 메뉴에서 카드 정보를 변경하실 수 있습니다.'), _buildFAQItem('Q. 결제 수단 변경은 어디서 하나요?', 'A. [마이페이지] > [결제 관리] 메뉴에서 카드 정보를 변경하실 수 있습니다.'),
], ],
@@ -324,7 +609,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
const Text('알림 권한이 꺼져 있나요?', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const Text('알림 권한이 꺼져 있나요?', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 10), const SizedBox(height: 10),
Text('중요한 헬멧 안전 경고 및 반납 알림을 받으려면 기기 설정에서 알림을 허용해야 합니다.', style: TextStyle(color: _subTextColor, height: 1.5)), Text('중요한 안전모 안전 경고 및 반납 알림을 받으려면 기기 설정에서 알림을 허용해야 합니다.', style: TextStyle(color: _subTextColor, height: 1.5)),
const SizedBox(height: 30), const SizedBox(height: 30),
_buildInfoLink('기기 설정으로 이동', Icons.settings, onTap: () => Navigator.pop(context)), _buildInfoLink('기기 설정으로 이동', Icons.settings, onTap: () => Navigator.pop(context)),
], ],
@@ -356,7 +641,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'헬멧 대여 및 반납 시 QR코드를 스캔하기 위해\n카메라 접근 권한이 반드시 필요합니다.\n권한을 거부하면 서비스를 이용할 수 없습니다.', '안전모 대여 및 반납 시 QR코드를 스캔하기 위해\n카메라 접근 권한이 반드시 필요합니다.\n권한을 거부하면 서비스를 이용할 수 없습니다.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: _subTextColor, height: 1.5), style: TextStyle(color: _subTextColor, height: 1.5),
), ),
@@ -609,6 +894,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
(val) => setState(() => _isEnvSensorAlert = val), (val) => setState(() => _isEnvSensorAlert = val),
showDivider: true, showDivider: true,
), ),
_buildToggleItem(
'이벤트 및 마케팅 알림',
'혜택 및 소식 받기',
_isMarketingAlertEnabled,
(val) => setState(() => _isMarketingAlertEnabled = val),
showDivider: true,
onTapLabel: () => _showTermContentSheet(context, '마케팅 수신 동의', _marketingTermContent),
),
_buildDivider(16), _buildDivider(16),
_buildInfoLink('알림 방식', Icons.notifications_active_outlined, value: '배너 + 진동', onTap: () => _showNotificationStyleSheet(context)), _buildInfoLink('알림 방식', Icons.notifications_active_outlined, value: '배너 + 진동', onTap: () => _showNotificationStyleSheet(context)),
], ],
@@ -655,7 +948,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
_buildInfoLink('버전 정보', null, value: _currentAppVersion, showArrow: false), _buildInfoLink('버전 정보', null, value: _currentAppVersion, showArrow: false),
_buildDivider(16), _buildDivider(16),
_buildInfoLink('이용 약관 및 개인정보 처리방침', Icons.article_outlined, onTap: () => _showTermsSheet(context)), _buildInfoLink('서비스 이용약관', Icons.description_outlined, onTap: () => _showTermContentSheet(context, '서비스 이용약관', _serviceTermContent)),
_buildDivider(16),
_buildInfoLink('개인정보 처리방침', Icons.privacy_tip_outlined, onTap: () => _showTermContentSheet(context, '개인정보 처리방침', _privacyTermContent)),
_buildDivider(16),
_buildInfoLink('위치기반 서비스 이용약관', Icons.location_on_outlined, onTap: () => _showTermContentSheet(context, '위치기반 서비스 이용약관', _locationTermContent)),
_buildDivider(16), _buildDivider(16),
_buildInfoLink('오픈소스 라이선스', Icons.code, onTap: () => _showLicenseSheet(context)), _buildInfoLink('오픈소스 라이선스', Icons.code, onTap: () => _showLicenseSheet(context)),
_buildDivider(16), _buildDivider(16),
@@ -671,13 +968,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 40), const SizedBox(height: 40),
Center( Center(
child: TextButton( child: TextButton(
onPressed: () {}, onPressed: () {
_showDeleteAccountDialog(context);
},
child: Text( child: Text(
'회원 탈퇴 신청', '회원 탈퇴 신청',
style: TextStyle( style: TextStyle(
color: _warningColor, color: Colors.black,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w500,
fontSize: 14, fontSize: 12,
), ),
), ),
), ),
@@ -722,7 +1021,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _buildToggleItem( Widget _buildToggleItem(
String title, String subtitle, bool value, ValueChanged<bool> onChanged, String title, String subtitle, bool value, ValueChanged<bool> onChanged,
{required bool showDivider}) { {required bool showDivider, VoidCallback? onTapLabel}) {
return Column( return Column(
children: [ children: [
Padding( Padding(
@@ -731,15 +1030,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded( Expanded(
child: GestureDetector(
onTap: onTapLabel,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Text(title, style: TextStyle(color: _mainTextColor, fontSize: 14, fontWeight: FontWeight.w500)), Text(title, style: TextStyle(color: _mainTextColor, fontSize: 14, fontWeight: FontWeight.w500)),
if (onTapLabel != null)
Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Icon(Icons.info_outline, size: 14, color: _subTextColor),
),
],
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(subtitle, style: TextStyle(color: _subTextColor, fontSize: 11)), Text(subtitle, style: TextStyle(color: _subTextColor, fontSize: 11)),
], ],
), ),
), ),
),
Transform.scale( Transform.scale(
scale: 0.8, scale: 0.8,
child: Switch( child: Switch(

View File

@@ -38,7 +38,7 @@ class CustomHeader extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
'스마트 헬멧 보관함', '스마트 안전모 보관함',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,