mirror of
https://github.com/immich-app/immich.git
synced 2026-07-14 09:00:59 +00:00
* 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>
581 lines
18 KiB
Dart
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);
|
|
}
|
|
}
|