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 createState() => _ProfileEditScreenState(); } class _ProfileEditScreenState extends State { 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 _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 createState() => _ProfilePhotoScreenState(); } class _ProfilePhotoScreenState extends State { 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 _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, ), ), ); }