Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/apps/photos/lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2762,6 +2762,7 @@
"privateAndSecureBackupsDesc": "End-to-End encrypted, triple replication, open source, and independently audited",
"mlProgressBannerTitle": "Hang tight!",
"mlProgressBannerDescription": "Your photos are getting processed on device to detect faces, objects and more.",
"mlProgressBannerPaused": "Paused",
"mlProgressBannerStatus": "{indexed} out of {total} photos processed",
"@mlProgressBannerStatus": {
"placeholders": {
Expand Down
175 changes: 161 additions & 14 deletions mobile/apps/photos/lib/ui/components/banners/ml_progress_banner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class _MLProgressBannerState extends State<MLProgressBanner> {
final l10n = AppLocalizations.of(context);
final format = NumberFormat();
final progress = total > 0 ? status.indexedItems.toDouble() / total : 0.0;
final showPausedPhase = _shouldShowPausedPhase(status);
final showModelDownloadPhase = _shouldShowModelDownloadPhase(status);

return Padding(
Expand Down Expand Up @@ -195,25 +196,42 @@ class _MLProgressBannerState extends State<MLProgressBanner> {
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(2.5),
child: LinearProgressIndicator(
value: showModelDownloadPhase ? 0.0 : progress,
minHeight: 5,
backgroundColor: colorScheme.fillFaint,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.greenBase,
),
),
child: showPausedPhase
? LinearProgressIndicator(
value: progress,
minHeight: 5,
backgroundColor: colorScheme.fillFaint,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.greenBase,
),
)
: showModelDownloadPhase
? LinearProgressIndicator(
value: 0.0,
minHeight: 5,
backgroundColor: colorScheme.fillFaint,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.greenBase,
),
)
: _ShimmerProgressBar(
progress: progress,
backgroundColor: colorScheme.fillFaint,
valueColor: colorScheme.greenBase,
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
showModelDownloadPhase
? l10n.loadingModel
: l10n.mlProgressBannerStatus(
indexed: format.format(status.indexedItems),
total: format.format(total),
),
showPausedPhase
? l10n.mlProgressBannerPaused
: showModelDownloadPhase
? l10n.loadingModel
: l10n.mlProgressBannerStatus(
indexed: format.format(status.indexedItems),
total: format.format(total),
),
style: textTheme.tinyMuted,
),
),
Expand All @@ -226,11 +244,17 @@ class _MLProgressBannerState extends State<MLProgressBanner> {

bool _shouldShowModelDownloadPhase(IndexStatus status) {
if (!localSettings.isMLLocalIndexingEnabled) return false;
if (_shouldShowPausedPhase(status)) return false;
if (status.indexedItems > 0) return false;
if (status.pendingItems <= 0) return false;
return !MLIndexingIsolate.instance.areModelsDownloaded;
}

bool _shouldShowPausedPhase(IndexStatus status) {
if (status.pendingItems <= 0) return false;
return !computeController.isDeviceHealthy;
}

void _onDismiss() {
setState(() {
_dismissed = true;
Expand All @@ -239,3 +263,126 @@ class _MLProgressBannerState extends State<MLProgressBanner> {
localSettings.setMLProgressBannerDismissed(true);
}
}

class _ShimmerProgressBar extends StatefulWidget {
final double progress;
final Color backgroundColor;
final Color valueColor;

const _ShimmerProgressBar({
required this.progress,
required this.backgroundColor,
required this.valueColor,
});

@override
State<_ShimmerProgressBar> createState() => _ShimmerProgressBarState();
}

class _ShimmerProgressBarState extends State<_ShimmerProgressBar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final clampedProgress = widget.progress.clamp(0.0, 1.0).toDouble();
final displayProgress = clampedProgress > 0 ? clampedProgress : 0.08;

return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final phase = Curves.easeInOutCubic.transform(_controller.value);
final pulseColor = Color.lerp(widget.valueColor, Colors.white, 0.55)!;

return SizedBox(
height: 5,
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(2.5),
),
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: displayProgress,
heightFactor: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(2.5),
child: LayoutBuilder(
builder: (context, constraints) {
final pulseWidth =
(constraints.maxWidth * 0.3).clamp(20.0, 52.0);
final travelDistance =
constraints.maxWidth + (pulseWidth * 2);
final left = (travelDistance * phase) - pulseWidth;

return Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: widget.valueColor,
),
),
Positioned(
left: left,
top: 0,
bottom: 0,
width: pulseWidth,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
pulseColor.withValues(alpha: 0.18),
pulseColor.withValues(alpha: 0.5),
pulseColor.withValues(alpha: 0.18),
Colors.transparent,
],
stops: const [0.0, 0.24, 0.5, 0.76, 1.0],
),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.07),
Colors.transparent,
Colors.black.withValues(alpha: 0.03),
],
stops: const [0.0, 0.56, 1.0],
),
),
),
],
);
},
),
),
),
),
),
);
},
);
}
}
101 changes: 101 additions & 0 deletions mobile/apps/photos/lib/ui/components/shimmer_loading.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import "package:flutter/material.dart";

class ShimmerLoading extends StatefulWidget {
final Widget child;
final Color baseColor;
final Color highlightColor;
final Duration duration;
final bool enabled;
final double glowIntensity;

const ShimmerLoading({
super.key,
required this.child,
required this.baseColor,
required this.highlightColor,
this.duration = const Duration(milliseconds: 1150),
this.enabled = true,
this.glowIntensity = 1,
});

@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}

class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
if (widget.enabled) {
_controller.repeat();
}
}

@override
void didUpdateWidget(covariant ShimmerLoading oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
_controller.duration = widget.duration;
}
if (!widget.enabled && _controller.isAnimating) {
_controller.stop();
} else if (widget.enabled && !_controller.isAnimating) {
_controller.repeat();
}
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return widget.child;
}

return AnimatedBuilder(
animation: _controller,
child: widget.child,
builder: (context, child) {
final gradient = _buildGlowPulseGradient();

return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) {
return gradient.createShader(bounds);
},
child: child!,
);
},
);
}

LinearGradient _buildGlowPulseGradient() {
final phase = _controller.value;
final pulse = phase <= 0.5 ? phase * 2 : (1 - phase) * 2;
final easedPulse = Curves.easeInOut.transform(pulse);
final intensity = widget.glowIntensity.clamp(0.0, 1.0).toDouble();
const minGlow = 0.10;
final maxGlow = (0.52 * intensity).clamp(minGlow, 0.9).toDouble();
final glowStrength = minGlow + ((maxGlow - minGlow) * easedPulse);
final pulseColor =
Color.lerp(widget.baseColor, widget.highlightColor, glowStrength)!;

return LinearGradient(
colors: [
pulseColor,
pulseColor,
],
stops: const [0.0, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
}
Loading