INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import 'my_data.dart';
import 'photo_picker.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
///
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
///
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
class ProfileEditScreen extends StatefulWidget {
const ProfileEditScreen({
super.key,
required this.nickname,
required this.repository,
});
final String nickname;
final MyRepository repository;
@override
State<ProfileEditScreen> createState() => _ProfileEditScreenState();
}
class _ProfileEditScreenState extends State<ProfileEditScreen> {
late final _nickname = TextEditingController(text: widget.nickname);
String? _error;
bool _saving = false;
@override
void initState() {
super.initState();
// 「팬」처럼 두 자 미만이면 완료가 잠기므로 예약어는 입력하는 즉시 안내한다.
_nickname.addListener(
() => setState(
() => _error = nicknameReserved(_nickname.text)
? context.l10n.nicknameReserved
: null,
),
);
}
@override
void dispose() {
_nickname.dispose();
super.dispose();
}
/// 닉네임이 바뀌고 **길이 조건까지 맞는가**. 정책 원장 B-52 가 2~20자로
/// 확정했고 검증 기준이 *20자 성공 · 21자 거부* 다. 길이를 화면에서 막지 않으면
/// 완료를 눌러야 규칙이 거부하고, 사용자는 사유 없이 실패만 본다.
bool get _changed {
final value = _nickname.text.trim();
return value != widget.nickname &&
value.characters.length >= nicknameMinLength &&
value.characters.length <= nicknameMaxLength;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: myTextActionBar(
context,
title: context.l10n.nicknameEdit,
onDone: _changed && !_saving ? _save : null,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500.
Container(
height: 58,
padding: const EdgeInsets.symmetric(horizontal: 32),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: _nickname,
// 21자째가 아예 입력되지 않는다. 입력은 받아 두고 완료에서 막으면
// 무엇이 문제인지 알기 어렵다.
maxLength: nicknameMaxLength,
style: myText(18, FontWeight.w500, height: 1),
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
),
),
),
// 명세 — 오류는 필드 아래 헬프 텍스트로.
if (_error case final message?)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
message,
style: myText(12, FontWeight.w500, color: AppColors.error),
),
),
],
),
);
Future<void> _save() async {
if (nicknameReserved(_nickname.text.trim())) {
setState(() => _error = context.l10n.nicknameReserved);
return;
}
setState(() => _saving = true);
try {
await widget.repository.updateNickname(_nickname.text.trim());
if (mounted) Navigator.of(context).pop(true);
} on NicknameTakenException {
if (mounted) {
setState(() {
_saving = false;
_error = context.l10n.nicknameTaken;
});
}
}
}
}
/// 「취소 · 제목 · 완료」 머리 — 시안 `374:15479` · `374:16029`: 양쪽 글자 14/600,
/// 완료는 잠겨 있으면 #474747.
PreferredSizeWidget myTextActionBar(
BuildContext context, {
required String title,
required VoidCallback? onDone,
}) => AppBar(
automaticallyImplyLeading: false,
leadingWidth: 72,
leading: _BarText(label: context.l10n.commonCancel, onTap: () => Navigator.of(context).pop()),
title: Text(title),
actions: [_BarText(label: context.l10n.commonDone, onTap: onDone)],
);
class _BarText extends StatelessWidget {
const _BarText({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
enabled: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
widthFactor: 1,
child: Text(
label,
style: myText(
14,
FontWeight.w600,
height: 1,
color: onTap == null
? const Color(0xFF474747)
: AppColors.onCosmic,
),
),
),
),
),
);
}
/// 프로필 사진 설정 — Figma `374:15479` · `374:16029` · `374:15486` 「앨범 항목」.
///
/// 명세 `549:22105` — *완료 디폴트 비활성화 · 사진 선택 또는 사진 촬영 후 활성화*
/// 명세 `549:22119` — *노출 영역 원형 고정 · 확대 축소, 드래그 제스처*
/// 명세 `549:22129` — *이미지 최신순으로 소팅*
///
/// 앨범 목록을 앱이 직접 그리지 않고 기기 사진 선택기를 띄운다. 최신순 정렬과
/// 앨범 탐색이 선택기 쪽의 일이라 앱이 사진 전체에 대한 접근을 받아 둘 필요가
/// 없다. **확대·축소·드래그로 잘라내는 편집은 아직 없다** — 자르기는 별도
/// 플러그인이 필요하고 이번 범위에 없다.
class ProfilePhotoScreen extends StatefulWidget {
const ProfilePhotoScreen({
super.key,
this.pickPhoto = pickPhotoWithPlugin,
this.current,
});
final PhotoPicker pickPhoto;
/// 지금 쓰는 사진. 새로 고르기 전까지 미리보기에 그린다.
final ImageProvider? current;
@override
State<ProfilePhotoScreen> createState() => _ProfilePhotoScreenState();
}
class _ProfilePhotoScreenState extends State<ProfilePhotoScreen> {
PickedPhoto? _selected;
String? _error;
@override
Widget build(BuildContext context) {
final selected = _selected;
final preview = selected != null
? MemoryImage(selected.bytes)
: widget.current;
// 시안 `374:15479` — 375 정사각 미리보기(원형 노출 영역) · 「앨범 항목」 줄 56 ·
// 92 칸 격자(간격 2). 첫 칸은 카메라, 둘째 칸은 기기 앨범 선택기다.
return Scaffold(
appBar: myTextActionBar(
context,
title: context.l10n.myEditProfile,
// 고르기 전까지 완료가 잠겨 있다.
onDone: selected == null
? null
: () => Navigator.of(context).pop(selected),
),
body: ListView(
children: [
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: const Color(0xFF4D4D4D),
child: ClipOval(
child: preview == null
? const ColoredBox(color: AppColors.cosmicSurfaceRaised)
: Image(image: preview, fit: BoxFit.cover),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Text(context.l10n.albumItems, style: myText(16, FontWeight.w600, height: 1)),
),
// 시안 4×3 격자 — 카메라 · 앨범 선택기 다음 열 칸은 #000 빈 칸(사진 목록은 기기 선택기가 그린다).
Wrap(
spacing: 2,
runSpacing: 2,
children: [
_Cell(
label: context.l10n.takePhoto,
color: const Color(0xFF1F1E1F),
onTap: () => _pick(PhotoSource.camera),
child: const ImageIcon(
AssetImage('assets/figma/icon-camera.png'),
size: 32,
color: AppColors.onCosmic,
),
),
_Cell(
label: context.l10n.pickFromAlbum,
color: AppColors.onCosmic,
onTap: () => _pick(PhotoSource.gallery),
child: preview == null
? const Icon(
Icons.photo_library_outlined,
color: Colors.black,
)
: Padding(
padding: const EdgeInsets.all(6),
child: ClipOval(
child: Image(image: preview, fit: BoxFit.cover),
),
),
),
for (var i = 0; i < 10; i++)
const SizedBox.square(
dimension: 92,
child: ColoredBox(color: Colors.black),
),
],
),
if (_error case final message?)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: myText(12, FontWeight.w500, color: AppColors.error),
),
),
],
),
);
}
/// 고른 그 자리에서 1차 검증한다. 업로드까지 가서 거부되면 사용자가 왜
/// 안 되는지 모른 채 기다린다.
Future<void> _pick(PhotoSource source) async {
final photo = await widget.pickPhoto(source);
if (photo == null || !mounted) return;
try {
checkProfilePhoto(photo);
} on PhotoRejected catch (error) {
setState(() {
_selected = null;
_error = error.message;
});
return;
}
setState(() {
_selected = photo;
_error = null;
});
}
}
/// 격자 한 칸 92×92.
class _Cell extends StatelessWidget {
const _Cell({
required this.label,
required this.color,
required this.onTap,
required this.child,
});
final String label;
final Color color;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 92,
height: 92,
color: color,
alignment: Alignment.center,
child: child,
),
),
);
}