Compare commits

..

5 Commits

Author SHA1 Message Date
Yaros
161c7e0b8b Merge branch 'main' into fix/save-album-sort 2025-09-22 19:16:59 +02:00
Yaros
28a8a8c89c Merge branch 'main' into fix/save-album-sort 2025-09-17 18:24:31 +02:00
Yaros
aa0732158b Merge branch 'main' into fix/save-album-sort 2025-09-17 14:40:05 +02:00
Yaros
555837046d fix(mobile): persist album layout 2025-09-17 14:39:51 +02:00
Yaros
f02bc73f2c fix(mobile): persist album sorting in settings 2025-09-16 23:39:58 +02:00
11 changed files with 121 additions and 75 deletions

View File

@@ -1360,8 +1360,6 @@
"my_albums": "My albums",
"name": "Name",
"name_or_nickname": "Name or nickname",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements": "Network Requirements",

View File

@@ -70,6 +70,9 @@ enum StoreKey<T> {
// Read-only Mode settings
readonlyModeEnabled<bool>._(138),
// Album grid/list view settings
albumGridView<bool>._(139),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View File

@@ -18,6 +18,9 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@@ -52,8 +55,22 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
void initState() {
super.initState();
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
setState(() {
sort = AlbumSort(mode: toRemoteAlbumSortMode(albumSortMode), isReverse: savedIsReverse);
isGrid = savedIsGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh();
});
@@ -83,6 +100,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() {
isGrid = !isGrid;
});
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
}
void changeFilter(QuickFilterMode mode) {
@@ -98,6 +116,11 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort;
});
final appSettings = ref.read(appSettingsServiceProvider);
final albumSortMode = toAlbumSortMode(sort.mode);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, albumSortMode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
await sortAlbums();
}
@@ -182,6 +205,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
onToggleViewMode: toggleViewMode,
onSortChanged: changeSort,
controller: menuController,
currentSortMode: sort.mode,
currentIsReverse: sort.isReverse,
),
isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
@@ -193,20 +218,45 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged, {this.controller});
const _SortButton(
this.onSortChanged, {
required this.initialSortMode,
required this.initialIsReverse,
this.controller,
});
final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller;
final RemoteAlbumSortMode initialSortMode;
final bool initialIsReverse;
@override
ConsumerState<_SortButton> createState() => _SortButtonState();
}
class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true;
late RemoteAlbumSortMode albumSortOption;
late bool albumSortIsReverse;
bool isSorting = false;
@override
void initState() {
super.initState();
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
}
@override
void didUpdateWidget(_SortButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
setState(() {
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
});
}
}
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode;
// Switch direction
@@ -466,6 +516,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
required this.isGrid,
required this.onToggleViewMode,
required this.onSortChanged,
required this.currentSortMode,
required this.currentIsReverse,
this.controller,
});
@@ -473,6 +525,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
final VoidCallback onToggleViewMode;
final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged;
final RemoteAlbumSortMode currentSortMode;
final bool currentIsReverse;
@override
Widget build(BuildContext context) {
@@ -482,7 +536,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SortButton(onSortChanged, controller: controller),
_SortButton(
onSortChanged,
controller: controller,
initialSortMode: currentSortMode,
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,

View File

@@ -50,6 +50,8 @@ enum AppSettingsEnum<T> {
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false);
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumFilter {
String? userId;
@@ -23,3 +24,37 @@ class AlbumSort {
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
}
}
RemoteAlbumSortMode toRemoteAlbumSortMode(AlbumSortMode mode) {
switch (mode) {
case AlbumSortMode.title:
return RemoteAlbumSortMode.title;
case AlbumSortMode.assetCount:
return RemoteAlbumSortMode.assetCount;
case AlbumSortMode.lastModified:
return RemoteAlbumSortMode.lastModified;
case AlbumSortMode.created:
return RemoteAlbumSortMode.created;
case AlbumSortMode.mostRecent:
return RemoteAlbumSortMode.mostRecent;
case AlbumSortMode.mostOldest:
return RemoteAlbumSortMode.mostOldest;
}
}
AlbumSortMode toAlbumSortMode(RemoteAlbumSortMode mode) {
switch (mode) {
case RemoteAlbumSortMode.title:
return AlbumSortMode.title;
case RemoteAlbumSortMode.assetCount:
return AlbumSortMode.assetCount;
case RemoteAlbumSortMode.lastModified:
return AlbumSortMode.lastModified;
case RemoteAlbumSortMode.created:
return AlbumSortMode.created;
case RemoteAlbumSortMode.mostRecent:
return AlbumSortMode.mostRecent;
case RemoteAlbumSortMode.mostOldest:
return AlbumSortMode.mostOldest;
}
}

View File

@@ -8,9 +8,6 @@ import ChangeDate from './change-date.svelte';
describe('ChangeDate component', () => {
const initialDate = DateTime.fromISO('2024-01-01');
const initialTimeZone = 'Europe/Berlin';
const targetDate = DateTime.fromISO('2024-01-01').setZone('UTC+1', {
keepLocalTime: true,
});
const currentInterval = {
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
end: DateTime.fromISO('2001-02-01T14:00:00+01:00'),
@@ -53,11 +50,7 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({
mode: 'absolute',
date: '2024-01-01T00:00:00.000+01:00',
dateTime: targetDate,
});
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' });
});
test('calls onCancel on cancel', async () => {
@@ -72,9 +65,7 @@ describe('ChangeDate component', () => {
describe('when date is in daylight saving time', () => {
const dstDate = DateTime.fromISO('2024-07-01');
const targetDate = DateTime.fromISO('2024-07-01').setZone('UTC+2', {
keepLocalTime: true,
});
test('should render correct timezone with offset', () => {
render(ChangeDate, { initialDate: dstDate, initialTimeZone, onCancel, onConfirm });
@@ -88,11 +79,7 @@ describe('ChangeDate component', () => {
await fireEvent.click(getConfirmButton());
expect(onConfirm).toHaveBeenCalledWith({
mode: 'absolute',
date: '2024-07-01T00:00:00.000+02:00',
dateTime: targetDate,
});
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' });
});
});

View File

@@ -4,7 +4,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
import { ConfirmModal, Field, Switch } from '@immich/ui';
import { mdiCalendarEdit } from '@mdi/js';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime, Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -17,8 +17,6 @@
timezoneInput?: boolean;
withDuration?: boolean;
currentInterval?: { start: DateTime; end: DateTime };
icon?: string;
confirmText?: string;
onCancel: () => void;
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
}
@@ -30,8 +28,6 @@
timezoneInput = true,
withDuration = true,
currentInterval = undefined,
icon = mdiCalendarEdit,
confirmText,
onCancel,
onConfirm,
}: Props = $props();
@@ -39,7 +35,6 @@
export type AbsoluteResult = {
mode: 'absolute';
date: string;
dateTime: DateTime<true>;
};
export type RelativeResult = {
@@ -196,15 +191,9 @@
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
// Create a DateTime object in this fixed-offset zone, preserving the local time.
const fixedOffsetDateTime = DateTime.fromObject(dtComponents.toObject(), {
zone: fixedOffsetZone,
}) as DateTime<true>;
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
onConfirm({
mode: 'absolute',
date: fixedOffsetDateTime.toISO({ includeOffset: true })!,
dateTime: fixedOffsetDateTime,
});
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
}
if (showRelative && (selectedDuration || selectedRelativeOption)) {
@@ -248,8 +237,7 @@
<ConfirmModal
confirmColor="primary"
{title}
{icon}
{confirmText}
icon={mdiCalendarEditOutline}
prompt="Please select a new date:"
disabled={!date.isValid}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}

View File

@@ -833,14 +833,15 @@
title="Navigate to Time"
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (result: AbsoluteResult | RelativeResult) => {
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (result.mode !== 'absolute') {
return;
}
const asset = await timelineManager.getClosestAssetToDate(result.dateTime.toObject());
if (asset) {
setFocusAsset(asset);
if (dateString.mode == 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}

View File

@@ -143,24 +143,3 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
}
}
}
export function findClosestGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
let closestMonth: MonthGroup | undefined;
let minDifference = Number.MAX_SAFE_INTEGER;
for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth;
// Calculate the absolute difference in months
const yearDiff = Math.abs(year - targetYearMonth.year);
const monthDiff = Math.abs(monthNum - targetYearMonth.month);
const totalDiff = yearDiff * 12 + monthDiff;
if (totalDiff < minDifference) {
minDifference = totalDiff;
closestMonth = month;
}
}
return closestMonth;
}

View File

@@ -16,7 +16,6 @@ import {
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil,
findMonthGroupForDate,
getAssetWithOffset,
@@ -524,13 +523,9 @@ export class TimelineManager {
}
async getClosestAssetToDate(dateTime: TimelineDateTime) {
let monthGroup = findMonthGroupForDate(this, dateTime);
const monthGroup = findMonthGroupForDate(this, dateTime);
if (!monthGroup) {
// if exact match not found, find closest
monthGroup = findClosestGroupForDate(this, dateTime);
if (!monthGroup) {
return;
}
return;
}
await this.loadMonthGroup(dateTime, { cancelable: false });
const asset = monthGroup.findClosest(dateTime);

View File

@@ -27,7 +27,6 @@
{ key: ['D', 'd'], action: $t('previous_or_next_day') },
{ key: ['M', 'm'], action: $t('previous_or_next_month') },
{ key: ['Y', 'y'], action: $t('previous_or_next_year') },
{ key: ['g'], action: $t('navigate_to_time') },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },