refactor: yeet old timeline (#27666)

* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
@@ -1,97 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_text_field.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@RoutePage()
class ActivitiesPage extends HookConsumerWidget {
const ActivitiesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activities = ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
final listViewScrollController = useScrollController();
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
// Scroll to the end of the list to show the newly added activity
await listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 200,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
return Scaffold(
appBar: AppBar(title: asset == null ? Text(album.name) : null),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.remoteId,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
// +1 to display an additional over-scroll space after the last element
itemCount: data.length + 1,
itemBuilder: (context, index) {
// Additional vertical gap after the last element
if (index == data.length) {
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.scaffoldBackgroundColor,
child: ActivityTextField(
isEnabled: album.activityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],
),
);
},
),
);
}
}
@@ -1,168 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@RoutePage()
class ChangeExperiencePage extends ConsumerStatefulWidget {
final bool switchingToBeta;
const ChangeExperiencePage({super.key, required this.switchingToBeta});
@override
ConsumerState createState() => _ChangeExperiencePageState();
}
class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
AsyncValue<bool> hasMigrated = const AsyncValue.loading();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _handleMigration());
}
Future<void> _handleMigration() async {
try {
await _performMigrationLogic().timeout(
const Duration(minutes: 3),
onTimeout: () async {
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
},
);
if (mounted) {
setState(() {
HapticFeedback.heavyImpact();
hasMigrated = const AsyncValue.data(true);
});
}
} catch (e, s) {
Logger("ChangeExperiencePage").severe("Error during migration", e, s);
if (mounted) {
setState(() {
hasMigrated = AsyncValue.error(e, s);
});
}
}
}
Future<void> _performMigrationLogic() async {
if (widget.switchingToBeta) {
final assetNotifier = ref.read(assetProvider.notifier);
if (assetNotifier.mounted) {
assetNotifier.dispose();
}
final albumNotifier = ref.read(albumProvider.notifier);
if (albumNotifier.mounted) {
albumNotifier.dispose();
}
// Cancel uploads
await Store.put(StoreKey.backgroundBackup, false);
ref
.read(backupProvider.notifier)
.configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {});
ref.read(backupProvider.notifier).setAutoBackup(false);
ref.read(backupProvider.notifier).cancelBackup();
ref.read(manualUploadProvider.notifier).cancelBackup();
// Start listening to new websocket events
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
await ref.read(driftProvider).reset();
await Store.put(StoreKey.shouldResetSync, true);
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
if (delay >= 1000) {
await Store.put(StoreKey.backupTriggerDelay, (delay / 1000).toInt());
}
final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (permission.isGranted) {
await ref.read(backgroundSyncProvider).syncLocal(full: true);
await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).disableService();
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(backgroundWorkerFgServiceProvider).disable();
}
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated.when(
data: (data) => const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0),
error: (error, stackTrace) => const Icon(Icons.error, color: Colors.red, size: 48.0),
loading: () => const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()),
),
),
const SizedBox(height: 16.0),
SizedBox(
width: 300.0,
child: AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated.when(
data: (data) => Text(
"Migration success!\nPlease close and reopen the app to apply changes",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
error: (error, stackTrace) => Text(
"Migration failed!\nError: $error",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
loading: () => Text(
"Data migration in progress...\nPlease wait and don't close this page",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
),
],
),
),
);
}
}
@@ -1,238 +0,0 @@
import 'dart:async';
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';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_title.provider.dart';
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_title_text_field.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_description.dart';
import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart';
@RoutePage()
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
final List<Asset>? assets;
const CreateAlbumPage({super.key, this.assets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty);
final albumTitleTextFieldFocusNode = useFocusNode();
final albumDescriptionTextFieldFocusNode = useFocusNode();
final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(assets != null ? Set.from(assets!) : const {});
void onBackgroundTapped() {
albumTitleTextFieldFocusNode.unfocus();
albumDescriptionTextFieldFocusNode.unfocus();
isAlbumTitleTextFieldFocus.value = false;
if (albumTitleController.text.isEmpty) {
albumTitleController.text = 'create_album_page_untitled'.tr();
isAlbumTitleEmpty.value = false;
ref.watch(albumTitleProvider.notifier).setAlbumTitle('create_album_page_untitled'.tr());
}
}
onSelectPhotosButtonPressed() async {
AssetSelectionPageResult? selectedAsset = await context.pushRoute<AssetSelectionPageResult?>(
AlbumAssetSelectionRoute(existingAssets: selectedAssets.value, canDeselect: true),
);
if (selectedAsset == null) {
selectedAssets.value = const {};
} else {
selectedAssets.value = selectedAsset.selectedAssets;
}
}
buildTitleInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10, left: 10),
child: AlbumTitleTextField(
isAlbumTitleEmpty: isAlbumTitleEmpty,
albumTitleTextFieldFocusNode: albumTitleTextFieldFocusNode,
albumTitleController: albumTitleController,
isAlbumTitleTextFieldFocus: isAlbumTitleTextFieldFocus,
),
);
}
buildDescriptionInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10, left: 10),
child: AlbumViewerEditableDescription(
albumDescription: '',
descriptionFocusNode: albumDescriptionTextFieldFocusNode,
),
);
}
buildTitle() {
if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 200, left: 18),
child: Text('create_shared_album_page_share_add_assets', style: context.textTheme.labelLarge).tr(),
),
);
}
return const SliverToBoxAdapter();
}
buildSelectPhotosButton() {
if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: FilledButton.icon(
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
backgroundColor: context.colorScheme.surfaceContainerHigh,
),
onPressed: onSelectPhotosButtonPressed,
icon: Icon(Icons.add_rounded, color: context.primaryColor),
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'create_shared_album_page_share_select_photos',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
),
),
);
}
return const SliverToBoxAdapter();
}
buildControlButton() {
return Padding(
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
child: SizedBox(
height: 42,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: onSelectPhotosButtonPressed,
labelText: "add_photos".tr(),
),
],
),
),
);
}
buildSelectedImageGrid() {
if (selectedAssets.value.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
return GestureDetector(
onTap: onBackgroundTapped,
child: SharedAlbumThumbnailImage(asset: selectedAssets.value.elementAt(index)),
);
}, childCount: selectedAssets.value.length),
),
);
}
return const SliverToBoxAdapter();
}
Future<void> createAlbum() async {
onBackgroundTapped();
var newAlbum = await ref
.watch(albumProvider.notifier)
.createAlbum(ref.read(albumTitleProvider), selectedAssets.value);
if (newAlbum != null) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
selectedAssets.value = {};
ref.read(albumTitleProvider.notifier).clearAlbumTitle();
ref.read(albumViewerProvider.notifier).disableEditAlbum();
unawaited(context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id)));
}
}
return Scaffold(
appBar: AppBar(
elevation: 0,
centerTitle: false,
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
onPressed: () {
selectedAssets.value = {};
context.maybePop();
},
icon: const Icon(Icons.close_rounded),
),
title: const Text('create_album').tr(),
actions: [
TextButton(
onPressed: albumTitleController.text.isNotEmpty ? createAlbum : null,
child: Text(
'create'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: albumTitleController.text.isNotEmpty ? context.primaryColor : context.themeData.disabledColor,
),
),
),
],
),
body: GestureDetector(
onTap: onBackgroundTapped,
child: CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: context.scaffoldBackgroundColor,
elevation: 5,
automaticallyImplyLeading: false,
pinned: true,
floating: false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(125.0),
child: Column(
children: [
buildTitleInputField(),
buildDescriptionInputField(),
if (selectedAssets.value.isNotEmpty) buildControlButton(),
],
),
),
),
buildTitle(),
buildSelectPhotosButton(),
buildSelectedImageGrid(),
],
),
),
);
}
}
@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
class GalleryStackedChildren extends HookConsumerWidget {
final ValueNotifier<int> stackIndex;
const GalleryStackedChildren(this.stackIndex, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final stackId = asset.stackId;
if (stackId == null) {
return const SizedBox();
}
final stackElements = ref.watch(assetStackStateProvider(stackId));
final showControls = ref.watch(showControlsProvider);
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: SizedBox(
height: 80,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
itemBuilder: (context, index) {
final currentAsset = stackElements.elementAt(index);
final assetId = currentAsset.remoteId;
if (assetId == null) {
return const SizedBox();
}
return Padding(
key: ValueKey(currentAsset.id),
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
stackIndex.value = index;
ref.read(currentAssetProvider.notifier).set(currentAsset);
},
child: Container(
width: 60,
height: 60,
decoration: index == stackIndex.value
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Image(
fit: BoxFit.cover,
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""),
),
),
),
),
);
},
),
),
),
);
}
}
@@ -1,438 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart';
import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
@RoutePage()
// ignore: must_be_immutable
/// Expects [currentAssetProvider] to be set before navigating to this page
class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex;
final int heroOffset;
final bool showStack;
final RenderList renderList;
GalleryViewerPage({
super.key,
required this.renderList,
this.initialIndex = 0,
this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalAssets = useState(renderList.totalAssets);
final isZoomed = useState(false);
final stackIndex = useState(0);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
GlobalKey getVideoPlayerKey(int id) {
videoPlayerKeys.value.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys.value[id]!;
}
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
log.severe('Error precaching next image: $exception, $stackTrace');
}
try {
if (index < totalAssets.value && index >= 0) {
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(asset: asset, width: context.width, height: context.height),
context,
onError: onError,
);
}
} catch (e) {
// swallow error silently
log.severe('Error precaching next image: $e');
await context.maybePop();
}
}
useEffect(() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
// Delay this a bit so we can finish loading the page
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(currentIndex.value + 1);
});
return null;
}, const []);
useEffect(() {
final asset = loadAsset(currentIndex.value);
if (asset.isRemote) {
ref.read(castProvider.notifier).loadMediaOld(asset, false);
} else {
if (isCasting) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 1),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
});
}
}
return null;
}, [ref.watch(castProvider).isCasting]);
void showInfo() {
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
showModalBottomSheet(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
barrierColor: Colors.transparent,
isScrollControlled: true,
showDragHandle: true,
enableDrag: true,
context: context,
useSafeArea: true,
builder: (context) {
return DraggableScrollableSheet(
minChildSize: 0.5,
maxChildSize: 1,
initialChildSize: 0.75,
expand: false,
builder: (context, scrollController) {
return Padding(
padding: EdgeInsets.only(bottom: context.viewInsets.bottom),
child: ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
? AdvancedBottomSheet(assetDetail: asset, scrollController: scrollController)
: DetailPanel(asset: asset, scrollController: scrollController),
);
},
);
},
);
}
void handleSwipeUpDown(DragUpdateDetails details) {
const int sensitivity = 15;
const int dxThreshold = 50;
const double ratioThreshold = 3.0;
if (isZoomed.value) {
return;
}
// Guard [localPosition] null
if (localPosition.value == null) {
return;
}
// Check for delta from initial down point
final d = details.localPosition - localPosition.value!;
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
if (d.dx.abs() > dxThreshold) {
return;
}
final ratio = d.dy / max(d.dx.abs(), 1);
if (d.dy > sensitivity && ratio > ratioThreshold) {
context.maybePop();
} else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
showInfo();
}
}
ref.listen(showControlsProvider, (_, show) {
if (show || Platform.isIOS) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return;
}
// This prevents the bottom bar from "dropping" while the controls are being hidden
Timer(const Duration(milliseconds: 100), () {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
});
});
PhotoViewGalleryPageOptions buildImage(Asset asset) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __, ___) {
localPosition.value = details.localPosition;
},
onDragUpdate: (_, details, __) {
handleSwipeUpDown(details);
},
onTapDown: (ctx, tapDownDetails, _) {
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
ref.read(showControlsProvider.notifier).toggle();
return;
}
double tapX = tapDownDetails.globalPosition.dx;
double screenWidth = ctx.width;
// We want to change images if the user taps in the leftmost or
// rightmost quarter of the screen
bool tappedLeftSide = tapX < screenWidth / 4;
bool tappedRightSide = tapX > screenWidth * (3 / 4);
int? currentPage = controller.page?.toInt();
int maxPage = renderList.totalAssets - 1;
if (tappedLeftSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != 0) {
controller.jumpToPage(currentPage - 1);
}
} else if (tappedRightSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != maxPage) {
controller.jumpToPage(currentPage + 1);
}
} else {
ref.read(showControlsProvider.notifier).toggle();
}
},
onLongPressStart: asset.isMotionPhoto
? (_, __, ___) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
}
: null,
imageProvider: ImmichImage.imageProvider(asset: asset),
heroAttributes: _getHeroAttributes(asset),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.99,
minScale: PhotoViewComputedScale.contained * 0.99,
errorBuilder: (context, error, stackTrace) => ImmichImage(asset, fit: BoxFit.contain),
);
}
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __, ___) => localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: _getHeroAttributes(asset),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: getVideoPlayerKey(asset.id),
asset: asset,
image: Image(
key: ValueKey(asset),
image: ImmichImage.imageProvider(asset: asset, width: context.width, height: context.height),
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
),
),
);
}
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
var newAsset = loadAsset(index);
final stackId = newAsset.stackId;
if (stackId != null && currentIndex.value == index) {
final stackElements = ref.read(assetStackStateProvider(newAsset.stackId!));
if (stackIndex.value < stackElements.length) {
newAsset = stackElements.elementAt(stackIndex.value);
}
}
if (newAsset.isImage && !isPlayingMotionVideo) {
return buildImage(newAsset);
}
return buildVideo(context, newAsset);
}
return PopScope(
// Change immersive mode back to normal "edgeToEdge" mode
onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGallery.builder(
key: const ValueKey('gallery'),
scaleStateChangedCallback: (state) {
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
}
},
gaplessPlayback: true,
loadingBuilder: (context, event, index) {
final asset = loadAsset(index);
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10)),
ImmichThumbnail(key: ValueKey(asset), asset: asset, fit: BoxFit.contain),
],
),
);
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value, _) {
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
final newAsset = loadAsset(value);
currentIndex.value = value;
stackIndex.value = 0;
ref.read(currentAssetProvider.notifier).set(newAsset);
// Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(next);
});
context.scaffoldMessenger.hideCurrentSnackBar();
// send image to casting if the server has it
if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMediaOld(newAsset, false);
} else {
context.scaffoldMessenger.clearSnackBars();
if (isCasting) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
}
},
builder: buildAsset,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: GalleryAppBar(key: const ValueKey('app-bar'), showInfo: showInfo),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Column(
children: [
GalleryStackedChildren(stackIndex),
BottomGalleryBar(
key: const ValueKey('bottom-bar'),
renderList: renderList,
totalAssets: totalAssets,
controller: controller,
showStack: showStack,
stackIndex: stackIndex,
assetIndex: currentIndex,
),
],
),
),
const DownloadPanel(),
],
),
),
);
}
@pragma('vm:prefer-inline')
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
return PhotoViewHeroAttributes(
tag: asset.isInDb ? asset.id + heroOffset : '${asset.remoteId}-$heroOffset',
transitionOnUserGestures: true,
);
}
}
@@ -1,282 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final Asset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
const NativeVideoViewerPage({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoId = asset.id.toString();
final controller = useState<NativeVideoPlayerController?>(null);
final shouldPlayOnForeground = useRef(true);
final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.isLocal);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isVideoReady = useState(false);
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
try {
final local = asset.local;
if (local != null && asset.livePhotoVideoId == null) {
final file = await local.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file);
return source;
}
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/${asset.remoteId}/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${asset.fileName}: $error');
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(asset.aspectRatio);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe('Error getting aspect ratio for asset ${asset.fileName}: $error');
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.onNativePlaybackReady();
isVideoReady.value = true;
try {
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
if (autoPlayVideo) {
await notifier.play();
}
await notifier.setVolume(1);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
}
void onPlaybackPositionChanged() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
}
void onPlaybackEnded() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
final videoController = controller.value;
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
final source = await videoSource;
if (source == null) {
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
unawaited(
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await notifier.setLoop(loopVideo);
controller.value = nc;
}
ref.listen(currentAssetProvider, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await notifier.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await notifier.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
if (!isVideoReady.value || asset.isMotionPhoto) Center(key: ValueKey(asset.id), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
),
),
),
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
],
);
}
}
+7 -12
View File
@@ -2,14 +2,11 @@ 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/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart';
@@ -38,8 +35,7 @@ enum SettingSection {
Widget get widget => switch (this) {
SettingSection.advanced => const AdvancedSettings(),
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup =>
Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(),
SettingSection.backup => const DriftBackupSettings(),
SettingSection.freeUpSpace => const FreeUpSpaceSettings(),
SettingSection.languages => const LanguageSettings(),
SettingSection.networking => const NetworkingSettings(),
@@ -74,13 +70,12 @@ class _MobileLayout extends StatelessWidget {
.expand(
(setting) => setting == SettingSection.beta
? [
if (Store.isBetaTimelineEnabled)
SettingsCard(
icon: Icons.sync_outlined,
title: 'sync_status'.tr(),
subtitle: 'sync_status_subtitle'.tr(),
settingRoute: const SyncStatusRoute(),
),
SettingsCard(
icon: Icons.sync_outlined,
title: 'sync_status'.tr(),
subtitle: 'sync_status_subtitle'.tr(),
settingRoute: const SyncStatusRoute(),
),
]
: [
SettingsCard(
+21 -76
View File
@@ -12,13 +12,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -27,6 +23,8 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl, LaunchMode;
class BootstrapErrorWidget extends StatelessWidget {
@@ -323,29 +321,27 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;
bool syncSuccess = false;
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
if (syncSuccess) {
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();
}
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
} catch (e) {
log.severe('Failed establishing connection to the server: $e');
@@ -368,58 +364,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
// clean install - change the default of the flag
// current install not using beta timeline
if (context.router.current.name == SplashScreenRoute.name) {
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
if (needBetaMigration) {
bool migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("New Timeline Experience"),
content: const Text(
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
if (migrate != true) {
migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Are you sure?"),
content: const Text(
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
}
await Store.put(StoreKey.needBetaMigration, false);
if (migrate) {
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
return;
}
}
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
}
if (Store.isBetaTimelineEnabled) {
return;
}
final hasPermission = await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
// Resume backup (if enable) then navigate
await ref.watch(backupProvider.notifier).resumeBackup();
unawaited(context.replaceRoute(const TabShellRoute()));
}
}
@@ -1,142 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class TabControllerPage extends HookConsumerWidget {
const TabControllerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRefreshingAssets = ref.watch(assetProvider);
final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider);
final isScreenLandscape = MediaQuery.orientationOf(context) == Orientation.landscape;
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -18,
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(context.primaryColor),
),
),
),
],
);
}
void onNavigationSelected(TabsRouter router, int index) {
// On Photos page menu tapped
if (router.activeIndex == 0 && index == 0) {
scrollToTopNotifierProvider.scrollToTop();
}
// On Search page tapped
if (router.activeIndex == 1 && index == 1) {
ref.read(searchInputFocusProvider).requestFocus();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
}
final navigationDestinations = [
NavigationDestination(
label: 'photos'.tr(),
icon: const Icon(Icons.photo_library_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(Icons.photo_library, color: context.primaryColor),
),
),
NavigationDestination(
label: 'search'.tr(),
icon: const Icon(Icons.search_rounded),
selectedIcon: Icon(Icons.search, color: context.primaryColor),
),
NavigationDestination(
label: 'albums'.tr(),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingRemoteAlbums,
icon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
),
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(Icons.space_dashboard_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
),
),
];
Widget bottomNavigationBar(TabsRouter tabsRouter) {
return NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index),
destinations: navigationDestinations,
);
}
Widget navigationRail(TabsRouter tabsRouter) {
return NavigationRail(
destinations: navigationDestinations
.map((e) => NavigationRailDestination(icon: e.icon, label: Text(e.label), selectedIcon: e.selectedIcon))
.toList(),
onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index),
selectedIndex: tabsRouter.activeIndex,
labelType: NavigationRailLabelType.all,
groupAlignment: 0.0,
);
}
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: [const PhotosRoute(), SearchRoute(), const AlbumsRoute(), const LibraryRoute()],
duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: isScreenLandscape
? Row(
children: [
navigationRail(tabsRouter),
const VerticalDivider(),
Expanded(child: child),
],
)
: child,
bottomNavigationBar: multiselectEnabled || isScreenLandscape ? null : bottomNavigationBar(tabsRouter),
),
);
},
);
}
}