immich/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart
Thomas 5f87047490
feat(mobile): dynamic multi-line album name (#26040)
* feat(mobile): dynamic multi-line album name

Album names are currently limited to a single line, and scroll on
overflow. It would be better if album names were multi-line, and even
better if the font size was dynamic depending on how many lines there
are. The album name should then overflow with an ellipsis.

This is actually quite similar to how Google Photos handles album names.

* lint

---------

Co-authored-by: timonrieger <mail@timonrieger.de>
2026-02-15 22:23:45 +05:30

581 lines
18 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart';
class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@override
ConsumerState<RemoteAlbumSliverAppBar> createState() => _MesmerizingSliverAppBarState();
}
class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBar> {
double _scrollProgress = 0.0;
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context) {
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
if (currentAlbum == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
List<Shadow> actionIconShadows = [
if (_scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: isMultiSelectEnabled
? const SizedBox.shrink()
: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: actionIconColor,
shadows: actionIconShadows,
),
onPressed: () => context.maybePop(),
),
actions: [
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
widget.kebabMenu,
],
title: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
);
},
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
),
);
},
),
);
}
}
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
final IconData icon;
final void Function()? onEditTitle;
const _ExpandedBackground({required this.scrollProgress, required this.icon, this.onEditTitle});
@override
ConsumerState<_ExpandedBackground> createState() => _ExpandedBackgroundState();
}
class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with SingleTickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(duration: const Duration(milliseconds: 800), vsync: this);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1.5),
end: Offset.zero,
).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic));
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_slideController.forward();
}
});
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final timelineService = ref.watch(timelineServiceProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
if (currentAlbum == null) {
return const SizedBox.shrink();
}
final dateRange = ref.watch(remoteAlbumDateRangeProvider(currentAlbum.id));
return Stack(
fit: StackFit.expand,
children: [
Transform.translate(
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService, icon: widget.icon),
),
),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: widget.scrollProgress * 2.0, sigmaY: widget.scrollProgress * 2.0),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.6 + (widget.scrollProgress * 0.25)),
],
stops: const [0.0, 0.15, 0.55, 1.0],
),
),
),
),
),
Positioned(
bottom: 16,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (dateRange.hasValue)
Text(
DateRangeFormatting.formatDateRange(
dateRange.value!.$1.toLocal(),
dateRange.value!.$2.toLocal(),
context.locale,
),
style: const TextStyle(
color: Colors.white,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
),
const Text(
"",
style: TextStyle(
color: Colors.white,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
),
AnimatedContainer(duration: const Duration(milliseconds: 300), child: const _ItemCountText()),
],
),
GestureDetector(
onTap: widget.onEditTitle,
child: LayoutBuilder(
builder: (context, constraints) =>
_DynamicText(text: currentAlbum.name, maxWidth: constraints.maxWidth),
),
),
if (currentAlbum.description.isNotEmpty)
GestureDetector(
onTap: widget.onEditTitle,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 80),
child: SingleChildScrollView(
child: Text(
currentAlbum.description,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 8, color: Colors.black54)],
),
),
),
),
),
const Padding(padding: EdgeInsets.only(top: 8.0), child: RemoteAlbumSharedUserIcons()),
],
),
),
),
],
);
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
_reloadSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets));
return Text(
'items_count'.t(context: context, args: {"count": assetCount}),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.white,
shadows: [const Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black87)],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
final IconData icon;
const _RandomAssetBackground({required this.timelineService, required this.icon});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
animationBehavior: AnimationBehavior.preserve,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
_zoomController.forward().then((_) {
_loadNextAsset();
});
} else {
_zoomController.reverse().then((_) {
_loadNextAsset();
});
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}
class _DynamicText extends StatelessWidget {
final String text;
final double maxWidth;
const _DynamicText({required this.text, required this.maxWidth});
static const _baseTextStyle = TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black54)],
overflow: TextOverflow.ellipsis,
);
int _lineCount(double fontSize) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: _baseTextStyle.copyWith(fontSize: fontSize),
),
maxLines: 3,
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth);
return textPainter.computeLineMetrics().length;
}
double _fontSize() {
final fontSizes = [44.0, 36.0];
for (final fontSize in fontSizes) {
final lineCount = _lineCount(fontSize);
if (lineCount == 1) {
return fontSize;
}
}
return 28;
}
@override
Widget build(BuildContext context) {
return Text(text, style: _baseTextStyle.copyWith(fontSize: _fontSize()), maxLines: 3);
}
}