Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a95c0fe643 | |||
| 1867393f4a | |||
| a564d46017 |
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0-rc.4",
|
||||
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
|
||||
"label": "v3.0.0-rc.3",
|
||||
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1102,6 +1102,23 @@
|
||||
"favorite_action_prompt": "{count} added to Favorites",
|
||||
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
|
||||
"favorites": "Favorites",
|
||||
"feature_message_non_destructive_editing_body": "Edit your photos freely — the original is always kept untouched.",
|
||||
"feature_message_non_destructive_editing_title": "Non-destructive editing",
|
||||
"feature_message_ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
|
||||
"feature_message_ocr_title": "Search text in your photos",
|
||||
"feature_message_open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
|
||||
"feature_message_open_in_immich_title": "Open photos in Immich",
|
||||
"feature_message_recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
|
||||
"feature_message_recently_added_title": "Recently added",
|
||||
"feature_message_settings_subtitle": "See what's new in version {version}",
|
||||
"feature_message_share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
"feature_message_share_quality_title": "Choose your share quality",
|
||||
"feature_message_skip": "Skip",
|
||||
"feature_message_slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
|
||||
"feature_message_slideshow_title": "Slideshow",
|
||||
"feature_message_upload_to_album_body": "Add photos directly into an album as you upload them.",
|
||||
"feature_message_upload_to_album_title": "Upload straight to an album",
|
||||
"feature_message_version": "Version {version}",
|
||||
"feature_photo_updated": "Feature photo updated",
|
||||
"features": "Features",
|
||||
"features_setting_description": "Manage the app features",
|
||||
@@ -2224,6 +2241,7 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"whats_new": "What's new",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc4"
|
||||
version = "3.0.0rc3"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc4"
|
||||
version = "3.0.0rc3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
|
After Width: | Height: | Size: 502 B |
|
After Width: | Height: | Size: 502 B |
|
After Width: | Height: | Size: 504 B |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 500 B |
@@ -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';
|
||||
@@ -32,6 +33,7 @@ class AppConfig {
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
final FeatureMessageConfig featureMessage;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -46,6 +48,7 @@ class AppConfig {
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
this.featureMessage = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -61,6 +64,7 @@ class AppConfig {
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -74,6 +78,7 @@ class AppConfig {
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
featureMessage: featureMessage ?? this.featureMessage,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -91,15 +96,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 +165,7 @@ class AppConfig {
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
.featureMessageSeenVersion => featureMessage.seenVersion,
|
||||
})
|
||||
as T;
|
||||
|
||||
@@ -199,6 +219,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)),
|
||||
.featureMessageSeenVersion => copyWith(featureMessage: featureMessage.copyWith(seenVersion: value as int)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class FeatureMessageConfig {
|
||||
final int seenVersion;
|
||||
|
||||
const FeatureMessageConfig({this.seenVersion = 0});
|
||||
|
||||
FeatureMessageConfig copyWith({int? seenVersion}) =>
|
||||
FeatureMessageConfig(seenVersion: seenVersion ?? this.seenVersion);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is FeatureMessageConfig && other.seenVersion == seenVersion);
|
||||
|
||||
@override
|
||||
int get hashCode => seenVersion.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'FeatureMessageConfig(seenVersion: $seenVersion)';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
class FeatureHighlight {
|
||||
final String image;
|
||||
final String titleKey;
|
||||
final String bodyKey;
|
||||
|
||||
const FeatureHighlight({required this.image, required this.titleKey, required this.bodyKey});
|
||||
}
|
||||
|
||||
const int featureMessageHighlightVersion = 1;
|
||||
|
||||
const String featureMessageReleaseLabel = '3.0.0';
|
||||
|
||||
const List<FeatureHighlight> featureMessageHighlights = [
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/share_quality.webp',
|
||||
titleKey: 'feature_message_share_quality_title',
|
||||
bodyKey: 'feature_message_share_quality_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/slideshow.webp',
|
||||
titleKey: 'feature_message_slideshow_title',
|
||||
bodyKey: 'feature_message_slideshow_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/recently_added.webp',
|
||||
titleKey: 'feature_message_recently_added_title',
|
||||
bodyKey: 'feature_message_recently_added_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/non_destructive_editing.webp',
|
||||
titleKey: 'feature_message_non_destructive_editing_title',
|
||||
bodyKey: 'feature_message_non_destructive_editing_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/ocr.webp',
|
||||
titleKey: 'feature_message_ocr_title',
|
||||
bodyKey: 'feature_message_ocr_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/open_in_immich.webp',
|
||||
titleKey: 'feature_message_open_in_immich_title',
|
||||
bodyKey: 'feature_message_open_in_immich_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/upload_to_album.webp',
|
||||
titleKey: 'feature_message_upload_to_album_title',
|
||||
bodyKey: 'feature_message_upload_to_album_body',
|
||||
),
|
||||
];
|
||||
@@ -73,7 +73,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
|
||||
featureMessageSeenVersion<int>();
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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.read(SettingsKey.featureMessageSeenVersion);
|
||||
return featureMessageHighlights.isNotEmpty && featureMessageHighlightVersion > seen;
|
||||
}
|
||||
|
||||
Future<void> markSeen() =>
|
||||
_settingsRepository.write(SettingsKey.featureMessageSeenVersion, featureMessageHighlightVersion);
|
||||
}
|
||||
@@ -182,18 +182,6 @@ class TimelineService {
|
||||
return _buffer.slice(start, start + count);
|
||||
}
|
||||
|
||||
/// Reads a range without disturbing the buffer; queries the source if it isn't resident.
|
||||
Future<List<BaseAsset>> getAssetsRange(int index, int count) async {
|
||||
if (index < 0 || count <= 0 || index >= _totalAssets) {
|
||||
return const [];
|
||||
}
|
||||
final clamped = math.min(count, _totalAssets - index);
|
||||
if (hasRange(index, clamped)) {
|
||||
return getAssets(index, clamped);
|
||||
}
|
||||
return _assetSource(index, clamped);
|
||||
}
|
||||
|
||||
// Preload assets around the given index for asset viewer
|
||||
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:auto_route/auto_route.dart';
|
||||
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/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.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 +89,14 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
settings.add(
|
||||
SettingsCard(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: 'whats_new'.tr(),
|
||||
subtitle: 'feature_message_settings_subtitle'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
|
||||
onTap: () => showFeatureMessageDialog(context),
|
||||
),
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +126,13 @@ class _TabletLayout extends HookWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text('whats_new'.tr()),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
onTap: () => showFeatureMessageDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,14 +3,37 @@ 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((_) {
|
||||
if (!mounted || ref.read(featureMessageCheckedProvider)) {
|
||||
return;
|
||||
}
|
||||
ref.read(featureMessageCheckedProvider.notifier).state = true;
|
||||
|
||||
final service = ref.read(featureMessageServiceProvider);
|
||||
// if (service.shouldShow()) {
|
||||
showFeatureMessageDialog(context).then((_) => service.markSeen());
|
||||
// }
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
|
||||
@@ -49,21 +49,14 @@ class BaseActionButton extends ConsumerWidget {
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final onPressed = this.onPressed;
|
||||
|
||||
return MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
onPressed: onPressed == null
|
||||
? null
|
||||
: () {
|
||||
onPressed();
|
||||
MenuController.maybeOf(context)?.close();
|
||||
},
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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';
|
||||
|
||||
Future<void> showFeatureMessageDialog(BuildContext context) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'whats_new'.tr(),
|
||||
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> {
|
||||
final PageController _controller = PageController();
|
||||
int _index = 0;
|
||||
|
||||
bool get _isLast => _index >= featureMessageHighlights.length - 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (_isLast) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
|
||||
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('whats_new'.tr(), style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'feature_message_version'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: featureMessageHighlights.length,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (_, index) => _FeaturePage(highlight: featureMessageHighlights[index]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PageDots(controller: _controller, index: _index, count: featureMessageHighlights.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
|
||||
child: Text('feature_message_skip'.tr()),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _advance,
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(_isLast ? 'ok'.tr() : 'next'.tr(), key: ValueKey(_isLast)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: ColoredBox(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 300,
|
||||
child: Image.asset(
|
||||
highlight.image,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) =>
|
||||
Center(child: Icon(Icons.auto_awesome_outlined, color: scheme.onSurfaceVariant, size: 56)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
highlight.bodyKey.tr(),
|
||||
style: context.textTheme.bodyMedium?.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: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
|
||||
Future<void> showFeatureMessageSheet(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
useRootNavigator: true,
|
||||
builder: (_) => const _FeatureMessageSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _FeatureMessageSheet extends StatelessWidget {
|
||||
const _FeatureMessageSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
actions: const [],
|
||||
resizeOnScroll: false,
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
|
||||
child: Text(
|
||||
'whats_new'.tr(),
|
||||
style: context.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.separated(
|
||||
itemCount: featureMessageHighlights.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (_, index) => _HighlightCard(highlight: featureMessageHighlights[index]),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
footer: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: Text('feature_message_got_it'.tr()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightCard extends StatelessWidget {
|
||||
final FeatureHighlight highlight;
|
||||
|
||||
const _HighlightCard({required this.highlight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.asset(
|
||||
highlight.image,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) => ColoredBox(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.image_outlined, color: context.colorScheme.onSurfaceVariant, size: 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
// Tracks the [anchor..current] range selected by a drag. The in-buffer part of
|
||||
// each tick is selected synchronously so it follows the finger without racing;
|
||||
// the rare beyond-buffer part is read async (applied only if still in range) and
|
||||
// [end] reads whatever is still missing so the final range always lands.
|
||||
class DragSelectionController {
|
||||
DragSelectionController({required this.getAssetSafe, required this.getAssetsRange, required this.onChange});
|
||||
|
||||
final BaseAsset? Function(int index) getAssetSafe;
|
||||
final Future<List<BaseAsset>> Function(int index, int count) getAssetsRange;
|
||||
final void Function(Set<BaseAsset> select, Set<BaseAsset> deselect) onChange;
|
||||
|
||||
final HashMap<int, BaseAsset> _selected = HashMap();
|
||||
// Indices the buffer didn't hold yet (the edge outran the async buffer-load on a
|
||||
// fast scroll); read in by _extendPending and guaranteed by end().
|
||||
final Set<int> _pending = {};
|
||||
int? _anchor;
|
||||
int? _lo;
|
||||
int? _hi;
|
||||
bool _disposed = false;
|
||||
|
||||
// Call before starting a new drag so a previous drag's in-flight read can't
|
||||
// leak into the new selection.
|
||||
void dispose() => _disposed = true;
|
||||
|
||||
void _emit(Set<BaseAsset> select, Set<BaseAsset> deselect) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
onChange(select, deselect);
|
||||
}
|
||||
|
||||
void start(int anchor) {
|
||||
_selected.clear();
|
||||
_pending.clear();
|
||||
_anchor = anchor;
|
||||
_lo = anchor;
|
||||
_hi = anchor;
|
||||
_select(anchor);
|
||||
_extendPending();
|
||||
}
|
||||
|
||||
void enter(int current) {
|
||||
final anchor = _anchor;
|
||||
if (anchor == null || _lo == null || _hi == null) {
|
||||
return;
|
||||
}
|
||||
final ns = current < anchor ? current : anchor;
|
||||
final ne = current < anchor ? anchor : current;
|
||||
final ps = _lo!;
|
||||
final pe = _hi!;
|
||||
if (ns == ps && ne == pe) {
|
||||
return;
|
||||
}
|
||||
|
||||
final toSelect = <BaseAsset>{};
|
||||
final toDeselect = <BaseAsset>{};
|
||||
|
||||
_forEach(ps, ns - 1, (k) => _removeIndex(k, toDeselect));
|
||||
_forEach(ne + 1, pe, (k) => _removeIndex(k, toDeselect));
|
||||
_forEach(ns, ps - 1, (k) => _addIndex(k, toSelect));
|
||||
_forEach(pe + 1, ne, (k) => _addIndex(k, toSelect));
|
||||
|
||||
_lo = ns;
|
||||
_hi = ne;
|
||||
|
||||
if (toSelect.isNotEmpty || toDeselect.isNotEmpty) {
|
||||
_emit(toSelect, toDeselect);
|
||||
}
|
||||
_extendPending();
|
||||
}
|
||||
|
||||
Future<void> end() async {
|
||||
final lo = _lo;
|
||||
final hi = _hi;
|
||||
if (lo == null || hi == null) {
|
||||
return;
|
||||
}
|
||||
final missing = <int>[];
|
||||
for (var k = lo; k <= hi; k++) {
|
||||
if (!_selected.containsKey(k)) {
|
||||
missing.add(k);
|
||||
}
|
||||
}
|
||||
if (missing.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final from = missing.first;
|
||||
final assets = await getAssetsRange(from, missing.last - from + 1);
|
||||
final missingSet = missing.toSet();
|
||||
final toSelect = <BaseAsset>{};
|
||||
for (var i = 0; i < assets.length; i++) {
|
||||
final idx = from + i;
|
||||
if (missingSet.contains(idx) && !_selected.containsKey(idx)) {
|
||||
_selected[idx] = assets[i];
|
||||
_pending.remove(idx);
|
||||
toSelect.add(assets[i]);
|
||||
}
|
||||
}
|
||||
if (toSelect.isNotEmpty) {
|
||||
_emit(toSelect, const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
final asset = getAssetSafe(index);
|
||||
if (asset != null) {
|
||||
_selected[index] = asset;
|
||||
_pending.remove(index);
|
||||
_emit({asset}, const {});
|
||||
} else {
|
||||
_pending.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _addIndex(int index, Set<BaseAsset> toSelect) {
|
||||
if (_selected.containsKey(index)) {
|
||||
return;
|
||||
}
|
||||
final asset = getAssetSafe(index);
|
||||
if (asset != null) {
|
||||
_selected[index] = asset;
|
||||
_pending.remove(index);
|
||||
toSelect.add(asset);
|
||||
} else {
|
||||
_pending.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeIndex(int index, Set<BaseAsset> toDeselect) {
|
||||
_pending.remove(index);
|
||||
final asset = _selected.remove(index);
|
||||
if (asset != null) {
|
||||
toDeselect.add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extendPending() async {
|
||||
if (_pending.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var from = _pending.first;
|
||||
var to = _pending.first;
|
||||
for (final k in _pending) {
|
||||
if (k < from) {
|
||||
from = k;
|
||||
}
|
||||
if (k > to) {
|
||||
to = k;
|
||||
}
|
||||
}
|
||||
final assets = await getAssetsRange(from, to - from + 1);
|
||||
final toSelect = <BaseAsset>{};
|
||||
for (var i = 0; i < assets.length; i++) {
|
||||
final idx = from + i;
|
||||
if (_pending.contains(idx) && _lo != null && idx >= _lo! && idx <= _hi!) {
|
||||
_selected[idx] = assets[i];
|
||||
toSelect.add(assets[i]);
|
||||
}
|
||||
}
|
||||
_pending.removeWhere((idx) => _selected.containsKey(idx));
|
||||
if (toSelect.isNotEmpty) {
|
||||
_emit(toSelect, const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _forEach(int lo, int hi, void Function(int) fn) {
|
||||
for (var k = lo; k <= hi; k++) {
|
||||
fn(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.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/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
@@ -18,7 +20,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
@@ -28,27 +29,6 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
// First asset index of the row shown at [offset]. Pure for testing.
|
||||
@visibleForTesting
|
||||
int? assetIndexAtOffset(
|
||||
List<Segment> segments,
|
||||
double offset, {
|
||||
required int columnCount,
|
||||
required double maxScrollExtent,
|
||||
}) {
|
||||
final clamped = offset.clamp(0.0, maxScrollExtent);
|
||||
final segment = segments.findByOffset(clamped) ?? segments.lastOrNull;
|
||||
if (segment == null) {
|
||||
return null;
|
||||
}
|
||||
final rowIndex = segment.getMinChildIndexForScrollOffset(clamped);
|
||||
if (rowIndex > segment.firstIndex) {
|
||||
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
|
||||
return segment.firstAssetIndex + rowIndexInSegment * columnCount;
|
||||
}
|
||||
return segment.firstAssetIndex;
|
||||
}
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
@@ -160,10 +140,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Drag selection state
|
||||
static const _autoScrollStep = 175.0;
|
||||
static const _autoScrollDuration = Duration(milliseconds: 125);
|
||||
bool _dragging = false;
|
||||
DragSelectionController? _dragController;
|
||||
TimelineAssetIndex? _dragAnchorIndex;
|
||||
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||
ScrollPhysics? _scrollPhysics;
|
||||
|
||||
int _perRow = 4;
|
||||
@@ -247,18 +226,26 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) => _assetIndexAtOffset(segments, _scrollController.offset);
|
||||
|
||||
int? _assetIndexAtOffset(List<Segment> segments, double offset) => assetIndexAtOffset(
|
||||
segments,
|
||||
offset,
|
||||
columnCount: ref.read(timelineArgsProvider).columnCount,
|
||||
maxScrollExtent: _scrollController.position.maxScrollExtent,
|
||||
);
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||
final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
|
||||
int? targetAssetIndex;
|
||||
if (segment != null) {
|
||||
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
|
||||
if (rowIndex > segment.firstIndex) {
|
||||
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
|
||||
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
|
||||
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
|
||||
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
|
||||
} else {
|
||||
targetAssetIndex = segment.firstAssetIndex;
|
||||
}
|
||||
}
|
||||
return targetAssetIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dragController?.dispose();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
@@ -308,21 +295,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
// Drag selection methods
|
||||
void _setDragStartIndex(TimelineAssetIndex index) {
|
||||
// Stop the old drag's controller so its in-flight read can't leak into this one.
|
||||
_dragController?.dispose();
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
_dragController = DragSelectionController(
|
||||
getAssetSafe: timelineService.getAssetSafe,
|
||||
getAssetsRange: timelineService.getAssetsRange,
|
||||
onChange: (select, deselect) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).selectRange(select, deselect);
|
||||
},
|
||||
)..start(index.assetIndex);
|
||||
setState(() {
|
||||
_scrollPhysics = const ClampingScrollPhysics();
|
||||
_dragAnchorIndex = index;
|
||||
_dragging = true;
|
||||
});
|
||||
}
|
||||
@@ -338,12 +313,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
});
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_draggedAssets.clear();
|
||||
});
|
||||
// Apply the full final range even if a read is still in flight on lift.
|
||||
final finishing = _dragController?.end();
|
||||
if (finishing != null) {
|
||||
unawaited(finishing);
|
||||
}
|
||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
timelineState.setScrolling(false);
|
||||
@@ -351,33 +322,42 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _dragScroll(ScrollDirection direction) {
|
||||
final position = _scrollController.position;
|
||||
final step = direction == ScrollDirection.forward ? _autoScrollStep : -_autoScrollStep;
|
||||
final target = (_scrollController.offset + step).clamp(0.0, position.maxScrollExtent);
|
||||
_scrollController.animateTo(target, duration: _autoScrollDuration, curve: Curves.easeOut);
|
||||
|
||||
// A held finger emits no move events, so extend the selection to the asset
|
||||
// at the leading edge of the scroll instead.
|
||||
final controller = _dragController;
|
||||
if (controller == null) {
|
||||
return;
|
||||
}
|
||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
||||
if (segments == null) {
|
||||
return;
|
||||
}
|
||||
final edgeOffset = direction == ScrollDirection.forward ? target + position.viewportDimension : target;
|
||||
final edgeIndex = _assetIndexAtOffset(segments, edgeOffset);
|
||||
if (edgeIndex != null) {
|
||||
controller.enter(edgeIndex);
|
||||
}
|
||||
_scrollController.animateTo(
|
||||
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
|
||||
duration: const Duration(milliseconds: 125),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragAssetEnter(TimelineAssetIndex index) {
|
||||
if (!_dragging) {
|
||||
if (_dragAnchorIndex == null || !_dragging) {
|
||||
return;
|
||||
}
|
||||
_dragController?.enter(index.assetIndex);
|
||||
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final dragAnchorIndex = _dragAnchorIndex!;
|
||||
|
||||
// Calculate the range of assets to select
|
||||
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
|
||||
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
|
||||
final count = endIndex - startIndex + 1;
|
||||
|
||||
// Load the assets in the range
|
||||
if (timelineService.hasRange(startIndex, count)) {
|
||||
final selectedAssets = timelineService.getAssets(startIndex, count);
|
||||
|
||||
// Clear previous drag selection and add new range
|
||||
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
|
||||
for (final asset in _draggedAssets) {
|
||||
multiSelectNotifier.deselectAsset(asset);
|
||||
}
|
||||
_draggedAssets.clear();
|
||||
|
||||
for (final asset in selectedAssets) {
|
||||
multiSelectNotifier.selectAsset(asset);
|
||||
_draggedAssets.add(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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)),
|
||||
);
|
||||
|
||||
final featureMessageCheckedProvider = StateProvider<bool>((ref) => false);
|
||||
@@ -97,15 +97,6 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the previous drag range and adds the new one in a single update. The
|
||||
// full-set copy per drag tick is the accepted cost of immutable state.
|
||||
void selectRange(Set<BaseAsset> toSelect, Set<BaseAsset> toDeselect) {
|
||||
final selectedAssets = state.selectedAssets.toSet()
|
||||
..removeAll(toDeselect)
|
||||
..addAll(toSelect);
|
||||
state = state.copyWith(selectedAssets: selectedAssets);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ class SettingsCard extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.settingRoute,
|
||||
this.settingRoute,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final PageRouteInfo settingRoute;
|
||||
final PageRouteInfo? settingRoute;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -38,7 +40,7 @@ class SettingsCard extends StatelessWidget {
|
||||
),
|
||||
title: Text(title, style: context.textTheme.titleMedium!.copyWith(color: context.primaryColor)),
|
||||
subtitle: Text(subtitle, style: context.textTheme.bodyMedium),
|
||||
onTap: () => context.pushRoute(settingRoute),
|
||||
onTap: onTap ?? (settingRoute != null ? () => context.pushRoute(settingRoute!) : null),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.0-rc.4
|
||||
- API version: 3.0.0-rc.3
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0-rc.4+3052
|
||||
version: 3.0.0-rc.3+3051
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -119,6 +119,7 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/feature_message/
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
|
||||
void main() {
|
||||
// Two day-segments, 4 columns, 8 assets each (2 rows). header 50, tile 100, no spacing.
|
||||
// A: header@[0,50) rows@50,150 assets 0..7 offset [0,250]
|
||||
// B: header@[250,300) rows@300,400 assets 8..15 offset [250,500]
|
||||
const columnCount = 4;
|
||||
const maxScrollExtent = 500.0;
|
||||
final segments = <Segment>[
|
||||
const FixedSegment(
|
||||
firstIndex: 0,
|
||||
lastIndex: 2,
|
||||
startOffset: 0,
|
||||
endOffset: 250,
|
||||
firstAssetIndex: 0,
|
||||
bucket: Bucket(assetCount: 8),
|
||||
tileHeight: 100,
|
||||
columnCount: columnCount,
|
||||
headerExtent: 50,
|
||||
spacing: 0,
|
||||
header: HeaderType.day,
|
||||
),
|
||||
const FixedSegment(
|
||||
firstIndex: 3,
|
||||
lastIndex: 5,
|
||||
startOffset: 250,
|
||||
endOffset: 500,
|
||||
firstAssetIndex: 8,
|
||||
bucket: Bucket(assetCount: 8),
|
||||
tileHeight: 100,
|
||||
columnCount: columnCount,
|
||||
headerExtent: 50,
|
||||
spacing: 0,
|
||||
header: HeaderType.day,
|
||||
),
|
||||
];
|
||||
|
||||
int? at(double offset) =>
|
||||
assetIndexAtOffset(segments, offset, columnCount: columnCount, maxScrollExtent: maxScrollExtent);
|
||||
|
||||
test('maps an offset to the first asset of the row shown there', () {
|
||||
expect(at(0), 0); // top of segment A
|
||||
expect(at(150), 4); // second row of A
|
||||
expect(at(350), 8); // first row of B
|
||||
expect(at(450), 12); // second row of B
|
||||
});
|
||||
|
||||
test('clamps offsets outside the scroll range', () {
|
||||
expect(at(-100), 0); // below the top -> first asset
|
||||
expect(at(9999), at(maxScrollExtent)); // past the end -> same as the max offset
|
||||
});
|
||||
|
||||
test('returns null for empty segments', () {
|
||||
expect(assetIndexAtOffset(const [], 100, columnCount: columnCount, maxScrollExtent: maxScrollExtent), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
|
||||
|
||||
import '../../../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
const total = 50;
|
||||
late List<BaseAsset> all;
|
||||
late Set<int> inBuffer; // indices getAssetSafe resolves synchronously
|
||||
late List<Completer<List<BaseAsset>>> reads; // pending async reads, completed manually
|
||||
late List<({int from, int count})> readArgs;
|
||||
late Set<BaseAsset> selected;
|
||||
late DragSelectionController sut;
|
||||
|
||||
Set<BaseAsset> range(int lo, int hi) => {for (var i = lo; i <= hi; i++) all[i]};
|
||||
|
||||
void completeRead(int i) {
|
||||
final a = readArgs[i];
|
||||
reads[i].complete([for (var k = a.from; k < a.from + a.count && k < total; k++) all[k]]);
|
||||
}
|
||||
|
||||
Future<void> settle() => Future(() {});
|
||||
|
||||
setUp(() {
|
||||
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(3, '0')}'));
|
||||
inBuffer = {for (var i = 0; i < total; i++) i}; // default: everything buffered (sync)
|
||||
reads = [];
|
||||
readArgs = [];
|
||||
selected = {};
|
||||
sut = DragSelectionController(
|
||||
getAssetSafe: (i) => inBuffer.contains(i) ? all[i] : null,
|
||||
getAssetsRange: (from, count) {
|
||||
final c = Completer<List<BaseAsset>>();
|
||||
reads.add(c);
|
||||
readArgs.add((from: from, count: count));
|
||||
return c.future;
|
||||
},
|
||||
onChange: (select, deselect) {
|
||||
selected
|
||||
..addAll(select)
|
||||
..removeAll(deselect);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('live selection (all in buffer, synchronous)', () {
|
||||
test('dragging down selects the whole range as it grows', () {
|
||||
sut.start(2);
|
||||
sut.enter(6);
|
||||
sut.enter(12);
|
||||
expect(selected, range(2, 12));
|
||||
expect(reads, isEmpty, reason: 'everything buffered -> no async read');
|
||||
});
|
||||
|
||||
test('dragging back up toward the anchor deselects the shrunk tail', () {
|
||||
sut.start(2);
|
||||
sut.enter(12);
|
||||
expect(selected, range(2, 12));
|
||||
sut.enter(6); // reverse
|
||||
expect(selected, range(2, 6));
|
||||
sut.enter(3); // reverse more
|
||||
expect(selected, range(2, 3));
|
||||
});
|
||||
|
||||
test('dragging past the anchor flips the range', () {
|
||||
sut.start(10);
|
||||
sut.enter(14);
|
||||
expect(selected, range(10, 14));
|
||||
sut.enter(7); // crosses the anchor
|
||||
expect(selected, range(7, 10));
|
||||
});
|
||||
});
|
||||
|
||||
group('beyond-buffer (async)', () {
|
||||
setUp(() => inBuffer = {}); // nothing buffered -> every tile needs an async read
|
||||
|
||||
test('drag-end fills the full range even if every live read is still in flight', () async {
|
||||
sut.start(0);
|
||||
sut.enter(20);
|
||||
// simulate the real-rate race: none of the in-drag reads have completed
|
||||
expect(selected, isEmpty);
|
||||
|
||||
final ending = sut.end();
|
||||
// end() issues its own read for the missing range; complete it
|
||||
completeRead(reads.length - 1);
|
||||
await ending;
|
||||
|
||||
expect(selected, range(0, 20), reason: 'final range must always apply on drag-end');
|
||||
});
|
||||
|
||||
test('out-of-order live read completions never corrupt the selection', () async {
|
||||
sut.start(0); // issues read for [0,1]
|
||||
sut.enter(10); // issues read for [0,11]
|
||||
sut.enter(20); // issues read for [0,21]
|
||||
expect(reads.length, 3);
|
||||
|
||||
// complete newest first, then older ones (out of order)
|
||||
completeRead(2);
|
||||
await settle();
|
||||
completeRead(1);
|
||||
await settle();
|
||||
completeRead(0);
|
||||
await settle();
|
||||
|
||||
expect(selected, range(0, 20));
|
||||
|
||||
final ending = sut.end();
|
||||
await ending; // nothing missing -> no extra read
|
||||
expect(selected, range(0, 20));
|
||||
});
|
||||
|
||||
test('a disposed controller never emits when its in-flight read resolves', () async {
|
||||
sut.start(0); // read [0,1] (pending, in flight)
|
||||
sut.enter(20); // read [0,21] (pending, in flight)
|
||||
expect(selected, isEmpty);
|
||||
|
||||
// a new drag starts -> the old controller is disposed
|
||||
final ending = sut.end(); // issues end()'s fill read
|
||||
sut.dispose();
|
||||
|
||||
// every in-flight read for the old controller now resolves
|
||||
for (var i = 0; i < reads.length; i++) {
|
||||
if (!reads[i].isCompleted) {
|
||||
completeRead(i);
|
||||
}
|
||||
}
|
||||
await ending;
|
||||
await settle();
|
||||
|
||||
expect(selected, isEmpty, reason: 'a disposed controller must not leak into the new selection');
|
||||
});
|
||||
|
||||
test('a late read for tiles dragged back out of range is ignored', () async {
|
||||
sut.start(0); // read [0,1]
|
||||
sut.enter(20); // read [0,21]
|
||||
sut.enter(5); // shrink back; read [0,6]
|
||||
// complete the stale wide read AFTER the shrink
|
||||
completeRead(1); // [0,21]
|
||||
await settle();
|
||||
// indices 6..20 left the range -> must not be selected
|
||||
expect(selected.intersection(range(6, 20)), isEmpty);
|
||||
|
||||
final ending = sut.end();
|
||||
// complete any read end() issued for the (now smaller) missing range
|
||||
for (var i = 0; i < reads.length; i++) {
|
||||
if (!reads[i].isCompleted) {
|
||||
completeRead(i);
|
||||
}
|
||||
}
|
||||
await ending;
|
||||
expect(selected, range(0, 5));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
|
||||
import '../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
// total must exceed the sliding buffer so it cannot hold the whole library at once
|
||||
const total = 2000;
|
||||
late List<BaseAsset> all;
|
||||
late TimelineService sut;
|
||||
|
||||
TimelineService buildService() {
|
||||
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(5, '0')}'));
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async {
|
||||
final end = (index + count) > total ? total : index + count;
|
||||
return all.sublist(index, end);
|
||||
},
|
||||
bucketSource: () => Stream.value([const Bucket(assetCount: total)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> settle() => Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
setUp(() async {
|
||||
sut = buildService();
|
||||
await settle(); // let the bucket subscription load the first batch and set totalAssets
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sut.dispose();
|
||||
});
|
||||
|
||||
test('buffer holds the first batch but not the whole library', () {
|
||||
expect(sut.totalAssets, total);
|
||||
expect(sut.hasRange(0, kTimelineAssetLoadBatchSize), isTrue);
|
||||
expect(sut.hasRange(0, total), isFalse);
|
||||
});
|
||||
|
||||
// #27118 / #20855 mechanism: drag-selecting from a low anchor while the grid
|
||||
// auto-scrolls down slides the buffer forward to follow the finger. once the
|
||||
// buffer offset passes the anchor, hasRange(anchor, ...) is false and
|
||||
// _handleDragAssetEnter silently stops extending the selection.
|
||||
test('anchor drops out of the buffer after the grid scrolls down', () async {
|
||||
const anchor = 5;
|
||||
expect(sut.hasRange(anchor, 100), isTrue);
|
||||
|
||||
// the grid loads a far-down range as it auto-scrolls during the drag
|
||||
await sut.loadAssets(1500, 1);
|
||||
|
||||
const current = 1500;
|
||||
const count = current - anchor + 1;
|
||||
expect(sut.hasRange(anchor, 1), isFalse, reason: 'anchor is now below the buffer offset');
|
||||
expect(sut.hasRange(anchor, count), isFalse, reason: 'the full drag range is no longer resident');
|
||||
expect(() => sut.getAssets(anchor, count), throwsRangeError);
|
||||
});
|
||||
|
||||
// the fix: getAssetsRange returns the whole drag range regardless of the
|
||||
// buffer position, so the selection keeps extending while scrolling.
|
||||
group('getAssetsRange', () {
|
||||
test('returns a buffered range', () async {
|
||||
final assets = await sut.getAssetsRange(0, 50);
|
||||
expect(assets.length, 50);
|
||||
expect(assets.first, all[0]);
|
||||
expect(assets.last, all[49]);
|
||||
});
|
||||
|
||||
test('returns a range wider than the buffer', () async {
|
||||
final assets = await sut.getAssetsRange(0, total);
|
||||
expect(assets.length, total);
|
||||
expect(assets.first, all[0]);
|
||||
expect(assets.last, all[total - 1]);
|
||||
});
|
||||
|
||||
test('returns the anchor range after the buffer scrolled past the anchor', () async {
|
||||
const anchor = 5;
|
||||
await sut.loadAssets(1500, 1); // buffer slides forward, dropping the anchor
|
||||
expect(sut.hasRange(anchor, 1), isFalse);
|
||||
|
||||
const current = 1500;
|
||||
const count = current - anchor + 1;
|
||||
final assets = await sut.getAssetsRange(anchor, count);
|
||||
expect(assets.length, count);
|
||||
expect(assets.first, all[anchor]);
|
||||
expect(assets.last, all[current]);
|
||||
});
|
||||
|
||||
test('clamps a range that runs past the end and ignores invalid input', () async {
|
||||
final tail = await sut.getAssetsRange(total - 10, 100);
|
||||
expect(tail.length, 10);
|
||||
expect(await sut.getAssetsRange(-1, 10), isEmpty);
|
||||
expect(await sut.getAssetsRange(0, 0), isEmpty);
|
||||
expect(await sut.getAssetsRange(total, 10), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -16206,7 +16206,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.0-rc.4
|
||||
* 3.0.0-rc.3
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||