354 lines
11 KiB
Dart
354 lines
11 KiB
Dart
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';
|
||
import '../../core/ui/bottom_inset.dart';
|
||
|
||
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
|
||
///
|
||
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
|
||
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
|
||
///
|
||
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 정책 원장 B-52)** 이 대체했다 —
|
||
/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다.
|
||
///
|
||
/// 사진은 시안대로 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: withBottomInset(context, 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,
|
||
),
|
||
),
|
||
);
|
||
}
|