immich/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart
shenlong bd55f7e73a
chore: pump flutter_lints to 6.0 (#30664)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-08-15 16:55:45 +05:30

316 lines
12 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
Future<void> showFeatureMessageDialog(BuildContext context) {
return showGeneralDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: context.t.whats_new,
barrierColor: Colors.black.withValues(alpha: 0.55),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, _, _) => const _FeatureMessageDialog(),
transitionBuilder: (_, animation, _, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved), child: child),
);
},
);
}
class _FeatureMessageDialog extends StatefulWidget {
const _FeatureMessageDialog();
@override
State<_FeatureMessageDialog> createState() => _FeatureMessageDialogState();
}
class _FeatureMessageDialogState extends State<_FeatureMessageDialog> with SingleTickerProviderStateMixin {
static const double _radius = 24;
final PageController _controller = PageController();
late final AnimationController _borderController = AnimationController(
vsync: this,
duration: const Duration(seconds: 7),
)..repeat();
final List<FeatureHighlight> _highlights = visibleFeatureMessageHighlights;
int _index = 0;
bool get _isLast => _index >= _highlights.length - 1;
@override
void dispose() {
_controller.dispose();
_borderController.dispose();
super.dispose();
}
void _advance() {
if (_isLast) {
Navigator.of(context).pop();
return;
}
unawaited(_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic));
}
List<Color> _borderColors(BuildContext context) {
final scheme = context.colorScheme;
// Mute the hues toward the surface and drop opacity in dark mode to keep it gentle.
Color tone(Color c) => context.isDarkTheme ? Color.lerp(c, scheme.surface, 0.45)!.withValues(alpha: 0.6) : c;
return [tone(scheme.primary), tone(scheme.tertiary), tone(scheme.secondary), tone(scheme.primary)];
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
clipBehavior: Clip.antiAlias,
backgroundColor: context.isDarkTheme ? context.colorScheme.surfaceContainerLow : Colors.white,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(_radius))),
child: AnimatedBuilder(
animation: _borderController,
builder: (context, child) => CustomPaint(
foregroundPainter: _GradientBorderPainter(
rotation: _borderController.value,
colors: _borderColors(context),
radius: _radius,
strokeWidth: 3,
),
child: child,
),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.9, maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.t.whats_new,
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(
context.t.whats_new_version(version: featureMessageRelease.toString()),
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
),
const SizedBox(height: 32),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: _highlights.length,
onPageChanged: (i) => setState(() => _index = i),
itemBuilder: (_, index) => _FeaturePage(highlight: _highlights[index]),
),
),
const SizedBox(height: 8),
_PageDots(controller: _controller, index: _index, count: _highlights.length),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 26),
child: Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
child: Text(context.t.skip),
),
const SizedBox(width: 8),
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(100)),
boxShadow: [
// Soft wide primary glow.
BoxShadow(
color: context.primaryColor.withValues(alpha: 0.38),
blurRadius: 22,
spreadRadius: -4,
offset: const Offset(0, 10),
),
// Tight contact shadow for grounding.
BoxShadow(
color: context.primaryColor.withValues(alpha: 0.22),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: FilledButton(
onPressed: _advance,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0,
textStyle: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(_isLast ? context.t.ok : context.t.next, key: ValueKey(_isLast)),
),
),
),
),
],
),
),
],
),
),
),
);
}
}
class _GradientBorderPainter extends CustomPainter {
const _GradientBorderPainter({
required this.rotation,
required this.colors,
required this.radius,
this.strokeWidth = 3,
});
final double rotation;
final List<Color> colors;
final double radius;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rect = (Offset.zero & size).deflate(inset);
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius - inset));
final shader = SweepGradient(
transform: GradientRotation(rotation * 2 * math.pi),
colors: colors,
).createShader(rect);
final paint = Paint()
..shader = shader
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(_GradientBorderPainter oldDelegate) =>
oldDelegate.rotation != rotation || !listEquals(oldDelegate.colors, colors);
}
class _FeaturePage extends StatelessWidget {
final FeatureHighlight highlight;
const _FeaturePage({required this.highlight});
@override
Widget build(BuildContext context) {
final scheme = context.colorScheme;
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(18)),
child: SizedBox(
width: double.infinity,
height: 256,
child: highlight.image == null
? const FeatureMessagePlaceholder()
: Image.asset(
highlight.image!,
fit: BoxFit.contain,
errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
highlight.title(context.t),
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 24),
),
const SizedBox(height: 8),
Text(
highlight.body(context.t),
style: context.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
],
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
final PageController controller;
final int index;
final int count;
const _PageDots({required this.controller, required this.index, required this.count});
@override
Widget build(BuildContext context) {
final primary = context.primaryColor;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final page = controller.hasClients ? (controller.page ?? index.toDouble()) : index.toDouble();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final activeness = (1 - (page - i).abs()).clamp(0.0, 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
height: 7,
width: 7 + 16 * activeness,
decoration: BoxDecoration(
color: Color.lerp(context.colorScheme.surfaceContainerHighest, primary, activeness),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
);
}),
);
},
);
}
}