Files
fanuzu/app/lib/core/ui/app_dialog.dart
T

230 lines
7.6 KiB
Dart

import 'package:flutter/material.dart';
import '../i18n/app_locale.dart';
import '../theme/tokens.dart';
/// 앱 공통 확인 창 — Figma `2066:17799`(행성 생성 확인) · `554:24669`(설정 확인 팝업).
///
/// 카드 312 폭 · #282828 r12 · 안쪽 24/16 · 문구 16/600 가운데 정렬,
/// 그 아래 **취소(흰 테두리) · 실행(분홍 채움)을 가로로 반씩**(48 높이 · 간격 8 · r8).
/// 단일 버튼 알림(Figma `511:86714` · `554:23103`)은 실행 버튼 하나가 폭을 다 쓴다.
///
/// [content] 는 입력 칸이 있는 창(반려 사유 · 인증 제출 등)에서 문구 대신 넣는다.
class AppDialog extends StatefulWidget {
const AppDialog({
super.key,
required this.title,
this.body,
this.content,
this.cancelLabel,
required this.confirmLabel,
this.onConfirm,
});
final String title;
final String? body;
final Widget? content;
/// null 이면 실행 버튼 하나만 둔다.
final String? cancelLabel;
final String confirmLabel;
/// 실행 버튼을 누르면 창을 닫기 **전에** 돈다. 도는 동안 실행 버튼은 진행 중
/// 표시로 바뀌고 두 버튼 모두 눌리지 않는다(로그아웃처럼 정리가 걸리는 실행).
final Future<void> Function()? onConfirm;
@override
State<AppDialog> createState() => _AppDialogState();
}
class _AppDialogState extends State<AppDialog> {
bool _busy = false;
static const _shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)),
);
static TextStyle _text(double size, FontWeight weight, Color color) =>
TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: 1.5,
letterSpacing: size * -0.025,
color: color,
);
@override
Widget build(BuildContext context) {
Widget button(String label, bool value, {required bool filled}) {
final text = Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _text(
16,
FontWeight.w600,
filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
).copyWith(height: 1),
);
Future<void> pop() async {
final run = widget.onConfirm;
if (value && run != null) {
setState(() => _busy = true);
try {
await run();
} finally {
if (mounted) setState(() => _busy = false);
}
}
if (context.mounted) Navigator.of(context).pop(value);
}
return Expanded(
child: filled
? FilledButton(
onPressed: _busy ? null : pop,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
backgroundColor: AppColors.cosmicAccent,
disabledBackgroundColor: AppColors.cosmicAccent,
shape: _shape,
),
child: _busy
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.onCosmicAccent,
),
)
: text,
)
: OutlinedButton(
onPressed: _busy ? null : pop,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 12),
side: const BorderSide(color: AppColors.onCosmic),
shape: _shape,
),
child: text,
),
);
}
return Dialog(
backgroundColor: const Color(0xFF282828), // Figma 2066:17799 fill
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5, vertical: 24),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(AppRadius.md)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w600, AppColors.onCosmic),
),
if (widget.body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: _text(16, FontWeight.w500, AppColors.onCosmic)
.copyWith(height: 1.4),
),
],
if (widget.content case final c?) ...[
const SizedBox(height: 16),
c,
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
if (widget.cancelLabel case final c?) ...[
button(c, false, filled: false),
const SizedBox(width: 8),
],
button(widget.confirmLabel, true, filled: true),
],
),
],
),
),
);
}
}
const _barrier = Color(0xB3000000);
/// 취소 · 실행 두 갈래. 실행이면 true. [confirmLabel] 기본은 「변경」.
Future<bool> showConfirmDialog(
BuildContext context,
String title, {
String? body,
Widget? content,
String? cancelLabel,
String? confirmLabel,
Future<void> Function()? onConfirm,
}) async {
final r = await showDialog<bool>(
context: context,
barrierColor: _barrier,
// 실행이 도는 동안 바깥을 눌러 닫히면 진행 중 표시가 사라진 채 실행만 남는다.
barrierDismissible: onConfirm == null,
builder: (context) => AppDialog(
title: title,
body: body,
content: content,
cancelLabel: cancelLabel ?? context.l10n.commonCancel,
confirmLabel: confirmLabel ?? context.l10n.commonChange,
onConfirm: onConfirm,
),
);
return r ?? false;
}
/// 확인 하나뿐인 알림 — Figma `511:86714`(타이틀 가운데 · 확인 버튼 가운데).
Future<void> showAlertDialog(
BuildContext context,
String title, {
String? body,
String? confirmLabel,
}) => showDialog<void>(
context: context,
barrierColor: _barrier,
builder: (context) => AppDialog(
title: title,
body: body,
confirmLabel: confirmLabel ?? context.l10n.commonConfirm,
),
);
/// 아래에서 올라오고 닫으면 아래로 내려가는 전체 화면 — Figma 설명 「닫기 버튼 클릭 시, 하단
/// move out 페이지 종료」(`516:87382` 성운 생성 · `516:87457` 행성 생성 · `516:88164` 이클립스 상세).
/// 안드로이드 기본 전환(확대)과 iOS 기본 전환(옆으로)이 모두 이 동작이 아니라 따로 둔다.
Route<T> slideUpRoute<T>(WidgetBuilder builder) => PageRouteBuilder<T>(
pageBuilder: (context, _, _) => builder(context),
transitionsBuilder: (_, animation, _, child) => SlideTransition(
position: Tween(
begin: const Offset(0, 1),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation),
child: child,
),
);