509 lines
18 KiB
Dart
509 lines
18 KiB
Dart
import 'dart:ui';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../../constant/colors.dart';
|
|
import '../../constant/style.dart';
|
|
import '../../controller/functions/tts.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Config
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class _DC {
|
|
static const Duration animDuration = Duration(milliseconds: 280);
|
|
static const double blur = 20.0;
|
|
static const double radius = 24.0;
|
|
static const Color barrierColor = Color(0x66000000);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared animated wrapper — every dialog uses this
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class _DialogShell extends StatelessWidget {
|
|
final Widget child;
|
|
const _DialogShell({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TweenAnimationBuilder<double>(
|
|
duration: _DC.animDuration,
|
|
tween: Tween(begin: 0.0, end: 1.0),
|
|
curve: Curves.easeOutBack,
|
|
builder: (_, v, c) => Transform.scale(
|
|
scale: 0.88 + (0.12 * v),
|
|
child: Opacity(opacity: v.clamp(0.0, 1.0), child: c),
|
|
),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: _DC.blur, sigmaY: _DC.blur),
|
|
child: Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding:
|
|
const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared glass card
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class _GlassCard extends StatelessWidget {
|
|
final Widget child;
|
|
const _GlassCard({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(_DC.radius),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.white.withOpacity(0.95),
|
|
Colors.white.withOpacity(0.88),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.18),
|
|
blurRadius: 40,
|
|
spreadRadius: -4,
|
|
offset: const Offset(0, 16),
|
|
),
|
|
BoxShadow(
|
|
color: AppColor.primaryColor.withOpacity(0.08),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.6),
|
|
width: 1.2,
|
|
),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared bottom action row
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class _ActionRow extends StatelessWidget {
|
|
final VoidCallback onCancel;
|
|
final VoidCallback onConfirm;
|
|
final String confirmLabel;
|
|
final bool isDestructive;
|
|
|
|
const _ActionRow({
|
|
required this.onCancel,
|
|
required this.onConfirm,
|
|
this.confirmLabel = 'OK',
|
|
this.isDestructive = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(color: Colors.grey.withOpacity(0.15), width: 1),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Cancel
|
|
Expanded(
|
|
child: _ActionButton(
|
|
label: 'Cancel'.tr,
|
|
color: Colors.grey[600]!,
|
|
backgroundColor: Colors.grey.withOpacity(0.07),
|
|
onPressed: () {
|
|
HapticFeedback.lightImpact();
|
|
onCancel();
|
|
},
|
|
isLeft: true,
|
|
),
|
|
),
|
|
Container(width: 1, height: 52, color: Colors.grey.withOpacity(0.15)),
|
|
// Confirm
|
|
Expanded(
|
|
child: _ActionButton(
|
|
label: confirmLabel,
|
|
color: isDestructive ? AppColor.redColor : AppColor.primaryColor,
|
|
backgroundColor: isDestructive
|
|
? AppColor.redColor.withOpacity(0.07)
|
|
: AppColor.primaryColor.withOpacity(0.07),
|
|
onPressed: () {
|
|
HapticFeedback.mediumImpact();
|
|
onConfirm();
|
|
},
|
|
isLeft: false,
|
|
isBold: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActionButton extends StatelessWidget {
|
|
final String label;
|
|
final Color color;
|
|
final Color backgroundColor;
|
|
final VoidCallback onPressed;
|
|
final bool isLeft;
|
|
final bool isBold;
|
|
|
|
const _ActionButton({
|
|
required this.label,
|
|
required this.color,
|
|
required this.backgroundColor,
|
|
required this.onPressed,
|
|
required this.isLeft,
|
|
this.isBold = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onPressed,
|
|
borderRadius: BorderRadius.only(
|
|
bottomLeft: isLeft ? const Radius.circular(_DC.radius) : Radius.zero,
|
|
bottomRight:
|
|
!isLeft ? const Radius.circular(_DC.radius) : Radius.zero,
|
|
),
|
|
child: Container(
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.only(
|
|
bottomLeft:
|
|
isLeft ? const Radius.circular(_DC.radius) : Radius.zero,
|
|
bottomRight:
|
|
!isLeft ? const Radius.circular(_DC.radius) : Radius.zero,
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 15,
|
|
fontWeight: isBold ? FontWeight.w700 : FontWeight.w500,
|
|
letterSpacing: -0.2,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// TTS speak button
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class _SpeakButton extends StatefulWidget {
|
|
final List<String> texts;
|
|
const _SpeakButton({required this.texts});
|
|
|
|
@override
|
|
State<_SpeakButton> createState() => _SpeakButtonState();
|
|
}
|
|
|
|
class _SpeakButtonState extends State<_SpeakButton>
|
|
with SingleTickerProviderStateMixin {
|
|
bool _speaking = false;
|
|
late AnimationController _pulse;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pulse = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 900),
|
|
lowerBound: 0.92,
|
|
upperBound: 1.0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pulse.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onTap() async {
|
|
if (_speaking) return;
|
|
HapticFeedback.selectionClick();
|
|
setState(() => _speaking = true);
|
|
_pulse.repeat(reverse: true);
|
|
|
|
final tts = Get.put(TextToSpeechController());
|
|
for (final t in widget.texts) {
|
|
await tts.speakText(t);
|
|
}
|
|
|
|
_pulse.stop();
|
|
_pulse.reset();
|
|
if (mounted) setState(() => _speaking = false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: _onTap,
|
|
child: AnimatedBuilder(
|
|
animation: _pulse,
|
|
builder: (_, child) =>
|
|
Transform.scale(scale: _pulse.value, child: child),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 250),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: _speaking
|
|
? AppColor.primaryColor.withOpacity(0.15)
|
|
: AppColor.primaryColor.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(30),
|
|
border: Border.all(
|
|
color: AppColor.primaryColor.withOpacity(_speaking ? 0.4 : 0.15),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_speaking
|
|
? CupertinoIcons.speaker_3_fill
|
|
: CupertinoIcons.speaker_2_fill,
|
|
color: AppColor.primaryColor,
|
|
size: 16,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
_speaking ? 'Speaking...'.tr : 'Listen'.tr,
|
|
style: TextStyle(
|
|
color: AppColor.primaryColor,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MyDialog — title + text content
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class MyDialog extends GetxController {
|
|
void getDialog(
|
|
String title,
|
|
String? midTitle,
|
|
VoidCallback onPressed, {
|
|
IconData? icon,
|
|
bool isDestructive = false,
|
|
}) {
|
|
HapticFeedback.mediumImpact();
|
|
|
|
Get.dialog(
|
|
_DialogShell(
|
|
child: _GlassCard(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── Body ──────────────────────────────────────────────
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 20),
|
|
child: Column(
|
|
children: [
|
|
// Icon badge
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: (isDestructive
|
|
? AppColor.redColor
|
|
: AppColor.primaryColor)
|
|
.withOpacity(0.1),
|
|
border: Border.all(
|
|
color: (isDestructive
|
|
? AppColor.redColor
|
|
: AppColor.primaryColor)
|
|
.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Icon(
|
|
icon ??
|
|
(isDestructive
|
|
? Icons.warning_amber_rounded
|
|
: Icons.info_outline_rounded),
|
|
color: isDestructive
|
|
? AppColor.redColor
|
|
: AppColor.primaryColor,
|
|
size: 26,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Title
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: AppStyle.title.copyWith(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
|
|
if (midTitle != null && midTitle.isNotEmpty) ...[
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
midTitle,
|
|
textAlign: TextAlign.center,
|
|
style: AppStyle.subtitle.copyWith(
|
|
fontSize: 14.5,
|
|
height: 1.5,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// TTS button
|
|
_SpeakButton(
|
|
texts: [title, if (midTitle.isNotEmpty) midTitle]),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Actions ───────────────────────────────────────────
|
|
_ActionRow(
|
|
onCancel: () => Get.back(),
|
|
onConfirm: onPressed,
|
|
confirmLabel: 'OK'.tr,
|
|
isDestructive: isDestructive,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
barrierDismissible: true,
|
|
barrierColor: _DC.barrierColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// MyDialogContent — title + arbitrary widget content
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class MyDialogContent extends GetxController {
|
|
void getDialog(
|
|
String title,
|
|
Widget? content,
|
|
VoidCallback onPressed, {
|
|
IconData? icon,
|
|
bool isDestructive = false,
|
|
String confirmLabel = 'OK',
|
|
}) {
|
|
HapticFeedback.mediumImpact();
|
|
|
|
Get.dialog(
|
|
_DialogShell(
|
|
child: _GlassCard(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── Body ──────────────────────────────────────────────
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header row: icon + title + TTS
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: (isDestructive
|
|
? AppColor.redColor
|
|
: AppColor.primaryColor)
|
|
.withOpacity(0.1),
|
|
),
|
|
child: Icon(
|
|
icon ??
|
|
(isDestructive
|
|
? Icons.warning_amber_rounded
|
|
: Icons.tune_rounded),
|
|
color: isDestructive
|
|
? AppColor.redColor
|
|
: AppColor.primaryColor,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: AppStyle.title.copyWith(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
_SpeakButton(texts: [title]),
|
|
],
|
|
),
|
|
|
|
// Divider
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Divider(
|
|
height: 1,
|
|
color: Colors.grey.withOpacity(0.15),
|
|
),
|
|
),
|
|
|
|
// Content
|
|
if (content != null) content,
|
|
],
|
|
),
|
|
),
|
|
|
|
// ── Actions ───────────────────────────────────────────
|
|
_ActionRow(
|
|
onCancel: () => Get.back(),
|
|
onConfirm: onPressed,
|
|
confirmLabel: confirmLabel.tr,
|
|
isDestructive: isDestructive,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
barrierDismissible: true,
|
|
barrierColor: _DC.barrierColor,
|
|
);
|
|
}
|
|
}
|