224 lines
7.3 KiB
Dart
224 lines
7.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../i18n/app_locale.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
/// 한국어 어절 단위 줄바꿈 — 글자 사이에 WORD JOINER(U+2060)를 넣어 공백에서만 줄을 바꾼다.
|
|
/// Flutter 는 한글을 음절마다 끊어 「얻으세 / 요.」처럼 낱말 중간에서 줄을 바꾼다(10/6 실기기 D-4).
|
|
/// 한 어절이 한 줄보다 길면 그 어절만 글자 단위로 끊긴다(엔진 기본).
|
|
String keepWords(String text) => text.splitMapJoin(
|
|
RegExp(r'\s+'),
|
|
onNonMatch: (word) => word.characters.join('\u2060'),
|
|
);
|
|
|
|
/// 앱 공통 확인 창 — 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,
|
|
),
|
|
);
|