mirror of
https://github.com/immich-app/immich.git
synced 2026-07-15 21:32:32 +03:00
feat: new feature message (#29388)
* feat: new feature board * wip * wip * wip * lint * lint * pr feedback * pr feedback * i18n * i18n
This commit is contained in:
17
i18n/en.json
17
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",
|
||||
|
||||
BIN
mobile/assets/feature_message/ocr.webp
Normal file
BIN
mobile/assets/feature_message/ocr.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
mobile/assets/feature_message/open_in_immich.webp
Normal file
BIN
mobile/assets/feature_message/open_in_immich.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
mobile/assets/feature_message/recently_added.webp
Normal file
BIN
mobile/assets/feature_message/recently_added.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
mobile/assets/feature_message/share_quality.webp
Normal file
BIN
mobile/assets/feature_message/share_quality.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
mobile/assets/feature_message/slideshow.webp
Normal file
BIN
mobile/assets/feature_message/slideshow.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -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<T>(SettingsKey<T> 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
20
mobile/lib/domain/models/config/feature_message_config.dart
Normal file
20
mobile/lib/domain/models/config/feature_message_config.dart
Normal file
@@ -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)';
|
||||
}
|
||||
54
mobile/lib/domain/models/feature_message.model.dart
Normal file
54
mobile/lib/domain/models/feature_message.model.dart
Normal file
@@ -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<TargetPlatform> 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<FeatureHighlight> get visibleFeatureMessageHighlights =>
|
||||
featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList();
|
||||
|
||||
const List<FeatureHighlight> 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'),
|
||||
];
|
||||
@@ -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<T> {
|
||||
// Theme
|
||||
@@ -73,7 +74,10 @@ enum SettingsKey<T> {
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
|
||||
|
||||
// Feature message
|
||||
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
|
||||
@@ -139,6 +143,16 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _SemVerCodec extends _SettingsCodec<SemVer> {
|
||||
const _SemVerCodec();
|
||||
|
||||
@override
|
||||
String encode(SemVer value) => value.toString();
|
||||
|
||||
@override
|
||||
SemVer decode(String raw) => SemVer.fromString(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
16
mobile/lib/domain/services/feature_message.service.dart
Normal file
16
mobile/lib/domain/services/feature_message.service.dart
Normal file
@@ -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<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<MainTimelinePage> createState() => _MainTimelinePageState();
|
||||
}
|
||||
|
||||
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
||||
@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()),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
_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.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)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
7
mobile/lib/providers/feature_message.provider.dart
Normal file
7
mobile/lib/providers/feature_message.provider.dart
Normal file
@@ -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<FeatureMessageService>(
|
||||
(ref) => FeatureMessageService(ref.read(settingsProvider)),
|
||||
);
|
||||
@@ -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]),
|
||||
|
||||
@@ -1872,3 +1872,19 @@ class TabShellRoute extends PageRouteInfo<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [WhatsNewPage]
|
||||
class WhatsNewRoute extends PageRouteInfo<void> {
|
||||
const WhatsNewRoute({List<PageRouteInfo>? children})
|
||||
: super(WhatsNewRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'WhatsNewRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const WhatsNewPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/feature_message/
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
|
||||
Reference in New Issue
Block a user