Files
fanuzu/app/lib/features/my/profile_edit_screen.dart
T

354 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
);
}