diff --git a/i18n/en.json b/i18n/en.json index 1587fde604..9b307d0edb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1461,6 +1461,7 @@ "never": "Never", "new_album": "New Album", "new_api_key": "New API Key", + "new_feature": "New Feature", "new_password": "New password", "new_person": "New person", "new_pin_code": "New PIN code", @@ -1521,6 +1522,8 @@ "obtainium_configurator": "Obtainium Configurator", "obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link", "ocr": "OCR", + "ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.", + "ocr_title": "Search text in your photos", "official_immich_resources": "Official Immich Resources", "offline": "Offline", "offset": "Offset", @@ -1539,6 +1542,8 @@ "open": "Open", "open_calendar": "Open calendar", "open_in_browser": "Open in browser", + "open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.", + "open_in_immich_title": "Open photos in Immich", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", @@ -1697,7 +1702,9 @@ "recent": "Recent", "recent_searches": "Recent searches", "recently_added": "Recently added", + "recently_added_body": "Jump straight to everything you've added lately on a dedicated page.", "recently_added_page_title": "Recently Added", + "recently_added_title": "Recently added", "recently_taken": "Recently taken", "refresh": "Refresh", "refresh_encoded_videos": "Refresh encoded videos", @@ -1904,6 +1911,8 @@ "share_link": "Share Link", "share_original": "Use original (large)", "share_preview": "Use thumbnail (small)", + "share_quality_body": "Press and hold the share button to choose the image quality before you share.", + "share_quality_title": "Choose your share quality", "shared": "Shared", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -1985,16 +1994,19 @@ "sign_out": "Sign Out", "sign_up": "Sign up", "size": "Size", + "skip": "Skip", "skip_to_content": "Skip to content", "skip_to_folders": "Skip to folders", "skip_to_tags": "Skip to tags", "slideshow": "Slideshow", + "slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.", "slideshow_metadata_overlay_mode": "Overlay content", "slideshow_metadata_overlay_mode_description_only": "Description only", "slideshow_metadata_overlay_mode_full": "Full", "slideshow_repeat": "Repeat slideshow", "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", + "slideshow_title": "Slideshow", "smart_album": "Smart album", "some_assets_already_have_a_location_warning": "Some of the selected assets already have a location", "sort_albums_by": "Sort albums by...", @@ -2157,6 +2169,8 @@ "upload_status_errors": "Errors", "upload_status_uploaded": "Uploaded", "upload_success": "Upload success, refresh the page to see new upload assets.", + "upload_to_album_body": "For users that don't utilize the manual upload feature, you can now choose to add local photos directly into an album as you upload them, no need to upload then add to an album later anymore.", + "upload_to_album_title": "Upload straight to an album", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", "uploading_media": "Uploading media", @@ -2224,6 +2238,9 @@ "week": "Week", "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", + "whats_new": "What's new", + "whats_new_settings_subtitle": "See what's new in Immich", + "whats_new_version": "Version {version}", "when": "When", "width": "Width", "wifi_name": "Wi-Fi Name", diff --git a/mobile/assets/feature_message/ocr.webp b/mobile/assets/feature_message/ocr.webp new file mode 100644 index 0000000000..5465c4e06a Binary files /dev/null and b/mobile/assets/feature_message/ocr.webp differ diff --git a/mobile/assets/feature_message/open_in_immich.webp b/mobile/assets/feature_message/open_in_immich.webp new file mode 100644 index 0000000000..910bb9e397 Binary files /dev/null and b/mobile/assets/feature_message/open_in_immich.webp differ diff --git a/mobile/assets/feature_message/recently_added.webp b/mobile/assets/feature_message/recently_added.webp new file mode 100644 index 0000000000..f530e42802 Binary files /dev/null and b/mobile/assets/feature_message/recently_added.webp differ diff --git a/mobile/assets/feature_message/share_quality.webp b/mobile/assets/feature_message/share_quality.webp new file mode 100644 index 0000000000..8e78cfcbfe Binary files /dev/null and b/mobile/assets/feature_message/share_quality.webp differ diff --git a/mobile/assets/feature_message/slideshow.webp b/mobile/assets/feature_message/slideshow.webp new file mode 100644 index 0000000000..91a9a29667 Binary files /dev/null and b/mobile/assets/feature_message/slideshow.webp differ diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 30e828311b..6a66d7ed23 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/album_config.dart'; import 'package:immich_mobile/domain/models/config/backup_config.dart'; import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; +import 'package:immich_mobile/domain/models/config/feature_message_config.dart'; import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/network_config.dart'; @@ -16,6 +17,7 @@ import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/utils/semver.dart'; const defaultConfig = AppConfig(); @@ -32,6 +34,7 @@ class AppConfig { final BackupConfig backup; final NetworkConfig network; final ShareConfig share; + final FeatureMessageConfig featureMessage; const AppConfig({ this.logLevel = .info, @@ -46,6 +49,7 @@ class AppConfig { this.backup = const .new(), this.network = const .new(), this.share = const .new(), + this.featureMessage = const .new(), }); AppConfig copyWith({ @@ -61,6 +65,7 @@ class AppConfig { BackupConfig? backup, NetworkConfig? network, ShareConfig? share, + FeatureMessageConfig? featureMessage, }) => .new( logLevel: logLevel ?? this.logLevel, theme: theme ?? this.theme, @@ -74,6 +79,7 @@ class AppConfig { backup: backup ?? this.backup, network: network ?? this.network, share: share ?? this.share, + featureMessage: featureMessage ?? this.featureMessage, ); @override @@ -91,15 +97,29 @@ class AppConfig { other.album == album && other.backup == backup && other.network == network && - other.share == share); + other.share == share && + other.featureMessage == featureMessage); @override - int get hashCode => - Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share); + int get hashCode => Object.hash( + logLevel, + theme, + cleanup, + map, + timeline, + image, + viewer, + slideshow, + album, + backup, + network, + share, + featureMessage, + ); @override String toString() => - 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)'; + 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)'; T read(SettingsKey key) => (switch (key) { @@ -146,6 +166,7 @@ class AppConfig { .slideshowDuration => slideshow.duration, .slideshowLook => slideshow.look, .slideshowDirection => slideshow.direction, + .featureMessageSeenRelease => featureMessage.seenRelease, }) as T; @@ -199,6 +220,7 @@ class AppConfig { .slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)), .slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)), .slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)), + .featureMessageSeenRelease => copyWith(featureMessage: featureMessage.copyWith(seenRelease: value as SemVer)), }; } } diff --git a/mobile/lib/domain/models/config/feature_message_config.dart b/mobile/lib/domain/models/config/feature_message_config.dart new file mode 100644 index 0000000000..d5c7c69550 --- /dev/null +++ b/mobile/lib/domain/models/config/feature_message_config.dart @@ -0,0 +1,20 @@ +import 'package:immich_mobile/utils/semver.dart'; + +class FeatureMessageConfig { + final SemVer seenRelease; + + const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)}); + + FeatureMessageConfig copyWith({SemVer? seenRelease}) => + FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease); + + @override + bool operator ==(Object other) => + identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease); + + @override + int get hashCode => seenRelease.hashCode; + + @override + String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)'; +} diff --git a/mobile/lib/domain/models/feature_message.model.dart b/mobile/lib/domain/models/feature_message.model.dart new file mode 100644 index 0000000000..b3c9c459d3 --- /dev/null +++ b/mobile/lib/domain/models/feature_message.model.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/semver.dart'; + +class FeatureHighlight { + /// Asset path of the feature screenshot, or null to show a placeholder. + final String? image; + final String titleKey; + final String bodyKey; + final List platform; + + const FeatureHighlight({ + this.image, + required this.titleKey, + required this.bodyKey, + this.platform = const [.iOS, .android], + }); + + bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform); +} + +/// The release this batch of highlights was authored for. Content-defined: +/// bump it only when publishing a new batch, never from the running app version. +const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0); + +/// Highlights relevant to the current platform. +List get visibleFeatureMessageHighlights => + featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList(); + +const List featureMessageHighlights = [ + FeatureHighlight( + image: 'assets/feature_message/share_quality.webp', + titleKey: 'share_quality_title', + bodyKey: 'share_quality_body', + ), + FeatureHighlight( + image: 'assets/feature_message/slideshow.webp', + titleKey: 'slideshow_title', + bodyKey: 'slideshow_body', + ), + FeatureHighlight( + image: 'assets/feature_message/recently_added.webp', + titleKey: 'recently_added_title', + bodyKey: 'recently_added_body', + ), + + FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'), + FeatureHighlight( + image: 'assets/feature_message/open_in_immich.webp', + titleKey: 'open_in_immich_title', + bodyKey: 'open_in_immich_body', + platform: [.android], + ), + FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'), +]; diff --git a/mobile/lib/domain/models/settings_key.dart b/mobile/lib/domain/models/settings_key.dart index 2933c301b0..00c5286e07 100644 --- a/mobile/lib/domain/models/settings_key.dart +++ b/mobile/lib/domain/models/settings_key.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/utils/semver.dart'; enum SettingsKey { // Theme @@ -73,7 +74,10 @@ enum SettingsKey { slideshowRepeat(), slideshowDuration(), slideshowLook(codec: _EnumCodec(SlideshowLook.values)), - slideshowDirection(codec: _EnumCodec(SlideshowDirection.values)); + slideshowDirection(codec: _EnumCodec(SlideshowDirection.values)), + + // Feature message + featureMessageSeenRelease(codec: _SemVerCodec()); final _SettingsCodec? _codecOverride; @@ -139,6 +143,16 @@ final class _DateTimeCodec extends _SettingsCodec { DateTime decode(String raw) => DateTime.parse(raw); } +final class _SemVerCodec extends _SettingsCodec { + const _SemVerCodec(); + + @override + String encode(SemVer value) => value.toString(); + + @override + SemVer decode(String raw) => SemVer.fromString(raw); +} + final class _MapCodec extends _SettingsCodec> { final _SettingsCodec _keyCodec; final _SettingsCodec _valueCodec; diff --git a/mobile/lib/domain/services/feature_message.service.dart b/mobile/lib/domain/services/feature_message.service.dart new file mode 100644 index 0000000000..ff0fdda8f3 --- /dev/null +++ b/mobile/lib/domain/services/feature_message.service.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/domain/models/feature_message.model.dart'; +import 'package:immich_mobile/domain/models/settings_key.dart'; +import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; + +class FeatureMessageService { + final SettingsRepository _settingsRepository; + + const FeatureMessageService(this._settingsRepository); + + bool shouldShow() { + final seen = _settingsRepository.appConfig.featureMessage.seenRelease; + return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen; + } + + Future markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease); +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 65970ee294..d102fe0d3b 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/settings/advanced_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart'; @@ -87,6 +88,14 @@ class _MobileLayout extends StatelessWidget { ], ) .toList(); + settings.add( + SettingsCard( + icon: Icons.auto_awesome_outlined, + title: context.t.whats_new, + subtitle: context.t.whats_new_settings_subtitle, + settingRoute: const WhatsNewRoute(), + ), + ); return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]); } } @@ -116,6 +125,13 @@ class _TabletLayout extends HookWidget { ), ), ), + SliverToBoxAdapter( + child: ListTile( + title: Text('whats_new'.tr()), + leading: const Icon(Icons.auto_awesome_outlined), + onTap: () => context.pushRoute(const WhatsNewRoute()), + ), + ), ], ), ), diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 5ec946858d..b78bbc2979 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -3,14 +3,42 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart'; +import 'package:immich_mobile/providers/feature_message.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @RoutePage() -class MainTimelinePage extends ConsumerWidget { +class MainTimelinePage extends ConsumerStatefulWidget { const MainTimelinePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MainTimelinePageState(); +} + +class _MainTimelinePageState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + return; + } + final service = ref.read(featureMessageServiceProvider); + if (!service.shouldShow()) { + return; + } + + await service.markSeen(); + if (!mounted) { + return; + } + + await showFeatureMessageDialog(context); + }); + } + + @override + Widget build(BuildContext context) { final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); return Timeline( topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), diff --git a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart new file mode 100644 index 0000000000..e3c3f8ed6e --- /dev/null +++ b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart @@ -0,0 +1,73 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.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/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart'; + +@RoutePage() +class WhatsNewPage extends StatelessWidget { + const WhatsNewPage({super.key}); + + @override + Widget build(BuildContext context) { + final highlights = visibleFeatureMessageHighlights; + return Scaffold( + appBar: AppBar(centerTitle: false, title: Text(context.t.whats_new)), + body: ListView.separated( + padding: const EdgeInsets.only(top: 16, bottom: 64), + itemCount: highlights.length, + separatorBuilder: (_, __) => const SizedBox(height: 24), + itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]), + ), + ); + } +} + +class _HighlightCard extends StatelessWidget { + final FeatureHighlight highlight; + + const _HighlightCard({required this.highlight}); + + @override + Widget build(BuildContext context) { + final scheme = context.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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(), + ), + ), + ), + ), + const SizedBox(height: 12), + Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text( + highlight.bodyKey.tr(), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart new file mode 100644 index 0000000000..9727ec29e4 --- /dev/null +++ b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart @@ -0,0 +1,316 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/feature_message.model.dart'; +import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; + +Future showFeatureMessageDialog(BuildContext context) { + return showGeneralDialog( + 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(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 _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; + } + _controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic); + } + + List _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 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.titleKey.tr(), + style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, fontSize: 24), + ), + const SizedBox(height: 8), + Text( + highlight.bodyKey.tr(), + 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)), + ), + ); + }), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/feature_message/feature_message_placeholder.widget.dart b/mobile/lib/presentation/widgets/feature_message/feature_message_placeholder.widget.dart new file mode 100644 index 0000000000..b157d5078b --- /dev/null +++ b/mobile/lib/presentation/widgets/feature_message/feature_message_placeholder.widget.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/widgets/common/immich_logo.dart'; + +class _SplatColors { + static const primary = Color(0xFF4250AF); + static const info = Color(0xFF3B82F6); + static const success = Color(0xFF2FB457); + static const warning = Color(0xFFF2A73B); + static const danger = Color(0xFFE5484D); +} + +class FeatureMessagePlaceholder extends StatelessWidget { + const FeatureMessagePlaceholder({super.key}); + + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + + final cardColor = dark ? const Color(0xFF232228) : const Color(0xFFEEEDF4); + final tileColor = dark ? const Color(0xFF2B2A32) : const Color(0xFFFBFAFE); + final inkColor = dark ? const Color(0xFFE7E7EC) : const Color(0xFF1A1A1E); + + return Container( + width: double.infinity, + height: double.infinity, + clipBehavior: Clip.antiAlias, + // Fill a plain rectangle — the parent's ClipRRect handles the corner rounding, + // so the placeholder doesn't round its own corners inside that clip. + decoration: BoxDecoration(color: cardColor), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // ---- confetti motif (168 × 120 region) ---- + SizedBox( + width: 168, + height: 120, + child: Stack( + clipBehavior: Clip.none, + children: [ + // scattered confetti + Positioned(left: 6, top: 24, child: _dot(12, _SplatColors.primary)), + Positioned(left: 80, top: -2, child: _dot(9, _SplatColors.danger)), + Positioned(left: 148, top: 84, child: _dot(11, _SplatColors.success)), + Positioned(left: 140, top: 14, child: _bar(22, 8, 0.49, _SplatColors.danger)), // ~28° + Positioned(left: 2, top: 90, child: _bar(20, 8, -0.31, _SplatColors.info)), // ~-18° + // tilted spark tile + Positioned( + left: 46, + top: 18, + child: Transform.rotate( + angle: -0.105, // ~-6° + child: Container( + width: 84, + height: 84, + decoration: BoxDecoration( + color: tileColor, + borderRadius: const BorderRadius.all(Radius.circular(18)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0F122D).withValues(alpha: 0.22), + blurRadius: 22, + offset: const Offset(0, 10), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned(left: 12, top: 12, child: _dot(12, _SplatColors.warning)), + const ImmichLogo(size: 40), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + context.t.new_feature, + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: inkColor), + ), + ], + ), + ); + } + + static Widget _dot(double d, Color c) => Container( + width: d, + height: d, + decoration: BoxDecoration(color: c, shape: BoxShape.circle), + ); + + static Widget _bar(double w, double h, double angle, Color c) => Transform.rotate( + angle: angle, + child: Container( + width: w, + height: h, + decoration: BoxDecoration(color: c, borderRadius: const BorderRadius.all(Radius.circular(99))), + ), + ); +} diff --git a/mobile/lib/providers/feature_message.provider.dart b/mobile/lib/providers/feature_message.provider.dart new file mode 100644 index 0000000000..459464524d --- /dev/null +++ b/mobile/lib/providers/feature_message.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/feature_message.service.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; + +final featureMessageServiceProvider = Provider( + (ref) => FeatureMessageService(ref.read(settingsProvider)), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a5403e3966..34d29be945 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -38,6 +38,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; +import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart'; import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; @@ -131,6 +132,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: ProfilePictureCropRoute.page), AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]), AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), + AutoRoute(page: WhatsNewRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), AutoRoute(page: FolderRoute.page, guards: [_authGuard]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index bc7226c2ee..1876a784dc 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1872,3 +1872,19 @@ class TabShellRoute extends PageRouteInfo { }, ); } + +/// generated route for +/// [WhatsNewPage] +class WhatsNewRoute extends PageRouteInfo { + const WhatsNewRoute({List? children}) + : super(WhatsNewRoute.name, initialChildren: children); + + static const String name = 'WhatsNewRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const WhatsNewPage(); + }, + ); +} diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index a64daadc49..17e5afe262 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/feature_message.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -254,6 +255,7 @@ class LoginForm extends HookConsumerWidget { } unawaited(handleSyncFlow()); ref.read(websocketProvider.notifier).connect(); + unawaited(ref.read(featureMessageServiceProvider).markSeen()); unawaited(context.router.replaceAll([const TabShellRoute()])); return; } @@ -341,6 +343,7 @@ class LoginForm extends HookConsumerWidget { await getManageMediaPermission(); } unawaited(handleSyncFlow()); + unawaited(ref.read(featureMessageServiceProvider).markSeen()); unawaited(context.router.replaceAll([const TabShellRoute()])); return; } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index c2f99aef66..f151b03f29 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -119,6 +119,7 @@ flutter: uses-material-design: true assets: - assets/ + - assets/feature_message/ fonts: - family: GoogleSans fonts: