mirror of
https://github.com/immich-app/immich.git
synced 2026-06-17 12:22:25 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3facfe8d50 | |||
| 430e1fed7d | |||
| 9c698d6694 | |||
| 2cc920ccdc | |||
| ad9817c582 | |||
| 14f6f2c04f | |||
| 327521fa27 | |||
| 3be803d0c0 |
@@ -13,7 +13,9 @@ import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
|
||||
:::info Android verification
|
||||
Below are the SHA-256 fingerprints for the certificates signing the android applications.
|
||||
|
||||
- Playstore / Github releases:
|
||||
- Google Play releases:
|
||||
`5A:22:C1:83:47:54:05:F5:49:C4:EB:9F:B2:6C:2E:93:A3:EF:9C:57:66:15:0A:7A:F3:8C:8D:3F:E5:15:CC:D6`
|
||||
- GitHub releases:
|
||||
`86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20`
|
||||
- F-Droid releases:
|
||||
`FA:8B:43:95:F4:A6:47:71:A0:53:D1:C7:57:73:5F:A2:30:13:74:F5:3D:58:0D:D1:75:AA:F7:A1:35:72:9C:BF`
|
||||
|
||||
+1
-14
@@ -4,20 +4,7 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
|
||||
|
||||
## Setup
|
||||
|
||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
||||
2. Change to the immich directory and trust the mise config with `mise trust`.
|
||||
3. Install tools with mise: `mise install`.
|
||||
4. Run `flutter pub get` to install the dependencies.
|
||||
5. Run `make translation` to generate the translation file.
|
||||
6. Run `flutter run` to start the app.
|
||||
|
||||
## Translation
|
||||
|
||||
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then, from the `mobile/` directory, run
|
||||
|
||||
```bash
|
||||
make translation
|
||||
```
|
||||
See [setup](https://docs.immich.app/developer/setup) for how to set up the mobile build environment.
|
||||
|
||||
## Static Analysis
|
||||
|
||||
|
||||
@@ -138,7 +138,9 @@ class LocalSyncService {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
|
||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||
final getAlbumsTime = stopwatch.elapsedMilliseconds;
|
||||
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||
final getAllTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
await diffSortedLists(
|
||||
dbAlbums,
|
||||
@@ -148,10 +150,15 @@ class LocalSyncService {
|
||||
onlyFirst: removeAlbum,
|
||||
onlySecond: addAlbum,
|
||||
);
|
||||
final diffTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
await _nativeSyncApi.checkpointSync();
|
||||
stopwatch.stop();
|
||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
_log.info(
|
||||
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
|
||||
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
|
||||
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
|
||||
);
|
||||
} on PlatformException catch (e, s) {
|
||||
if (e.code == _kSyncCancelledCode) {
|
||||
_log.warning("Full device sync cancelled");
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:stream_transform/stream_transform.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -90,6 +92,7 @@ class TimelineFactory {
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
static final Logger _log = Logger('TimelineService');
|
||||
final TimelineAssetSource _assetSource;
|
||||
final TimelineBucketSource _bucketSource;
|
||||
final TimelineOrigin origin;
|
||||
@@ -105,37 +108,53 @@ class TimelineService {
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
|
||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
_bucketSubscription = watchBuckets().listen(
|
||||
(buckets) {
|
||||
_mutex.run(() async {
|
||||
try {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
_log.info(
|
||||
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
|
||||
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
|
||||
);
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
_buffer = [];
|
||||
} else {
|
||||
final int offset;
|
||||
final int count;
|
||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||
// we need to reset the buffer and load the first batch of assets.
|
||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||
offset = 0;
|
||||
count = kTimelineAssetLoadBatchSize;
|
||||
} else {
|
||||
offset = _bufferOffset;
|
||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
|
||||
}
|
||||
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
} catch (error, stack) {
|
||||
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
_buffer = await _assetSource(offset, count);
|
||||
_bufferOffset = offset;
|
||||
}
|
||||
|
||||
// change the state's total assets count only after the buffer is reloaded
|
||||
_totalAssets = totalAssets;
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
_log.severe('[$origin] bucket stream errored', error, stack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => _bucketSource().throttle(const Duration(milliseconds: 500), trailing: true);
|
||||
|
||||
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
|
||||
|
||||
@@ -164,6 +183,13 @@ class TimelineService {
|
||||
_buffer = await _assetSource(start, len);
|
||||
_bufferOffset = start;
|
||||
|
||||
if (!hasRange(index, count)) {
|
||||
_log.warning(
|
||||
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
|
||||
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
|
||||
);
|
||||
}
|
||||
|
||||
return getAssets(index, count);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class FixedSegment extends Segment {
|
||||
final double tileHeight;
|
||||
@@ -90,6 +91,7 @@ class FixedSegment extends Segment {
|
||||
}
|
||||
|
||||
class _FixedSegmentRow extends ConsumerWidget {
|
||||
static final Logger _log = Logger('TimelineRow');
|
||||
final int assetIndex;
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
@@ -109,8 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||
final inRange = timelineService.hasRange(assetIndex, assetCount);
|
||||
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
if (assetIndex == 0) {
|
||||
_log.info(
|
||||
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
|
||||
'branch=${inRange
|
||||
? "assets"
|
||||
: isScrubbing
|
||||
? "placeholder(scrubbing)"
|
||||
: "future(load)"}',
|
||||
);
|
||||
}
|
||||
|
||||
if (inRange) {
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
@@ -129,6 +143,13 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
_log.warning(
|
||||
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:intl/intl.dart' hide TextDirection;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
|
||||
}
|
||||
|
||||
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||
static final Logger _log = Logger('Scrubber');
|
||||
String? _lastLabel;
|
||||
double _thumbTopOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('Scrubber initState');
|
||||
_isDragging = false;
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
||||
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
|
||||
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
|
||||
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
|
||||
if (oldEnd != newEnd) {
|
||||
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
|
||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||
_monthCount = getMonthCount();
|
||||
}
|
||||
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
|
||||
_log.warning(
|
||||
'Scrubber dispose mid-scrub '
|
||||
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
|
||||
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
|
||||
);
|
||||
} else {
|
||||
_log.info('Scrubber dispose');
|
||||
}
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeOutTimer?.cancel();
|
||||
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_log.info('scrub dragStart');
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
if (_scrubberHeight <= 0) {
|
||||
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
|
||||
return;
|
||||
}
|
||||
|
||||
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
|
||||
if (maxScrollExtent <= 0) {
|
||||
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails _) {
|
||||
_log.info('scrub dragEnd -> setScrubbing(false)');
|
||||
_labelAnimationController.reverse();
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -71,14 +72,27 @@ class TimelineState {
|
||||
}
|
||||
|
||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
static final Logger _log = Logger('TimelineState');
|
||||
|
||||
void setScrubbing(bool isScrubbing) {
|
||||
if (state.isScrubbing != isScrubbing) {
|
||||
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrubbing: isScrubbing);
|
||||
}
|
||||
|
||||
void setScrolling(bool isScrolling) {
|
||||
if (state.isScrolling != isScrolling) {
|
||||
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
|
||||
}
|
||||
state = state.copyWith(isScrolling: isScrolling);
|
||||
}
|
||||
|
||||
static String _callSite() {
|
||||
final frames = StackTrace.current.toString().split('\n');
|
||||
return frames.length > 2 ? frames[2].trim() : 'unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||
}
|
||||
@@ -96,6 +110,11 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
|
||||
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
yield* timelineService.watchBuckets().map((buckets) {
|
||||
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger('TimelineService').info(
|
||||
'[${timelineService.origin}] segment layout: '
|
||||
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
|
||||
);
|
||||
return FixedSegmentBuilder(
|
||||
buckets: buckets,
|
||||
tileHeight: tileExtent,
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
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';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
@@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
static final Logger _log = Logger('Timeline');
|
||||
late final ScrollController _scrollController;
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_log.info('SliverTimeline initState');
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -179,6 +182,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
_log.info('event ${event.runtimeType}');
|
||||
switch (event) {
|
||||
case ScrollToTopEvent():
|
||||
{
|
||||
@@ -186,7 +190,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
timelineState.setScrubbing(true);
|
||||
_scrollController
|
||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||
.whenComplete(() => timelineState.setScrubbing(false));
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToTop animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
}
|
||||
|
||||
case ScrollToDateEvent scrollToDateEvent:
|
||||
@@ -246,6 +253,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_log.info('SliverTimeline dispose');
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
@@ -286,8 +294,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.whenComplete(() => timelineState.setScrubbing(false));
|
||||
.whenComplete(() {
|
||||
_log.info('ScrollToDate animation done -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
});
|
||||
} else {
|
||||
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
|
||||
timelineState.setScrubbing(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('TimelineProvider');
|
||||
|
||||
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
_log.info('main TimelineService built users=$timelineUsers');
|
||||
ref.onDispose(() {
|
||||
_log.info('main TimelineService disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||
if (currentUserId == null) {
|
||||
_log.info('timelineUsers: currentUserId=null -> []');
|
||||
return Stream.value([]);
|
||||
}
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
|
||||
_log.info('timelineUsers emission: $users');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Combobox from '$lib/components/shared-components/Combobox.svelte';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
|
||||
import { convertBCP47, getClosestAvailableLocale, langCodes, langs } from '$lib/utils/i18n';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
let { showSettingDescription = false }: Props = $props();
|
||||
|
||||
const langOptions = langs.map((lang) => ({ label: lang.name, value: lang.code }));
|
||||
const langOptions = langs.map((lang) => ({ label: lang.name, value: convertBCP47(lang.code) }));
|
||||
|
||||
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
|
||||
|
||||
const handleLanguageChange = async (newLang: string | undefined) => {
|
||||
if (newLang) {
|
||||
$lang = newLang;
|
||||
await i18nLocale.set(newLang);
|
||||
await i18nLocale.set(convertBCP47(newLang));
|
||||
await invalidateAll();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
import { browser } from '$app/environment';
|
||||
import { defaultLang } from '$lib/constants';
|
||||
import { getPreferredLocale } from '$lib/utils/i18n';
|
||||
import { convertBCP47, getPreferredLocale } from '$lib/utils/i18n';
|
||||
|
||||
// Locale to use for formatting dates, numbers, etc.
|
||||
export const locale = persisted('locale', 'default', {
|
||||
serializer: {
|
||||
parse: (text) => text || 'default',
|
||||
parse: (text) => convertBCP47(text) || 'default',
|
||||
stringify: (object) => object ?? '',
|
||||
},
|
||||
});
|
||||
@@ -14,7 +14,7 @@ export const locale = persisted('locale', 'default', {
|
||||
const preferredLocale = browser ? getPreferredLocale() : undefined;
|
||||
export const lang = persisted<string>('lang', preferredLocale || defaultLang.code, {
|
||||
serializer: {
|
||||
parse: (text) => text,
|
||||
parse: (text) => convertBCP47(text),
|
||||
stringify: (object) => object ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
import { convertBCP47, langs } from '$lib/utils/i18n';
|
||||
|
||||
interface DownloadRequestOptions<T = unknown> {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
@@ -47,7 +47,7 @@ interface DateFormatter {
|
||||
export const initLanguage = async () => {
|
||||
const preferenceLang = get(lang);
|
||||
for (const { code, loader } of langs) {
|
||||
register(code, loader);
|
||||
register(convertBCP47(code), loader);
|
||||
}
|
||||
|
||||
await init({ fallbackLocale: preferenceLang === 'dev' ? 'dev' : defaultLang.code, initialLocale: preferenceLang });
|
||||
|
||||
@@ -19,7 +19,7 @@ const fileCodes = Object.keys(modules)
|
||||
.map((path) => path.match(/\/(\w+)\.json$/)?.[1])
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const convertBCP47 = (code: string) => code.replaceAll('_', '-');
|
||||
export const convertBCP47 = (code: string) => code.replaceAll('_', '-');
|
||||
|
||||
export const langCodes = fileCodes.map((code) => convertBCP47(code));
|
||||
|
||||
|
||||
@@ -13,38 +13,6 @@ export interface PlacesGroup {
|
||||
places: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export interface PlacesGroupOptionMetadata {
|
||||
id: PlacesGroupBy;
|
||||
isDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [
|
||||
{
|
||||
id: PlacesGroupBy.None,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
{
|
||||
id: PlacesGroupBy.Country,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
export const findGroupOptionMetadata = (groupBy: string) => {
|
||||
// Default is no grouping
|
||||
const defaultGroupOption = groupOptionsMetadata[0];
|
||||
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
|
||||
};
|
||||
|
||||
export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => {
|
||||
const defaultGroupOption = PlacesGroupBy.None;
|
||||
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
|
||||
|
||||
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
|
||||
return defaultGroupOption;
|
||||
}
|
||||
return albumGroupOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------
|
||||
* Places Groups Collapse/Expand
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
type PlacesGroupOptionMetadata,
|
||||
collapseAllPlacesGroups,
|
||||
expandAllPlacesGroups,
|
||||
findGroupOptionMetadata,
|
||||
getSelectedPlacesGroupOption,
|
||||
groupOptionsMetadata,
|
||||
} from '$lib/utils/places-utils';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
} from '@mdi/js';
|
||||
import { collapseAllPlacesGroups, expandAllPlacesGroups } from '$lib/utils/places-utils';
|
||||
import { IconButton, Select } from '@immich/ui';
|
||||
import { mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
placesGroups: string[];
|
||||
@@ -27,48 +14,26 @@
|
||||
|
||||
let { placesGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
|
||||
$placesViewSettings.groupBy = id;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
|
||||
});
|
||||
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
|
||||
|
||||
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
|
||||
[PlacesGroupBy.None]: $t('group_no'),
|
||||
[PlacesGroupBy.Country]: $t('group_country'),
|
||||
});
|
||||
let options = $derived([
|
||||
{ value: PlacesGroupBy.None, label: $t('group_no') },
|
||||
{ value: PlacesGroupBy.Country, label: $t('group_country') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<!-- Search Places -->
|
||||
<div class="hidden h-10 md:block xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Group Places -->
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
title={$t('group_places_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: placesGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
<div title={$t('group_places_by')}>
|
||||
<Select {options} bind:value={$placesViewSettings.groupBy} class="w-fit min-w-50" />
|
||||
</div>
|
||||
|
||||
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
{#if $placesViewSettings.groupBy !== PlacesGroupBy.None}
|
||||
<span transition:slide={{ axis: 'x', duration: 250 }}>
|
||||
<!-- Expand Countries Groups -->
|
||||
<div class="hidden gap-0 xl:flex">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllPlacesGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -81,7 +46,6 @@
|
||||
<!-- Collapse Countries Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllPlacesGroups(placesGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { groupBy } from 'lodash-es';
|
||||
import PlacesCardGroup from './PlacesCardGroup.svelte';
|
||||
|
||||
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
|
||||
import { type PlacesGroup } from '$lib/utils/places-utils';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -78,9 +78,8 @@
|
||||
: places;
|
||||
});
|
||||
|
||||
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
|
||||
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
|
||||
const groupingFunction = $derived(groupOptions[userSettings.groupBy] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces = $derived(groupingFunction(filteredPlaces));
|
||||
|
||||
$effect(() => {
|
||||
searchResultCount = filteredPlaces.length;
|
||||
@@ -93,7 +92,7 @@
|
||||
|
||||
{#if places.length > 0}
|
||||
<!-- Album Cards -->
|
||||
{#if placesGroupOption === PlacesGroupBy.None}
|
||||
{#if userSettings.groupBy === PlacesGroupBy.None}
|
||||
<PlacesCardGroup places={groupedPlaces[0].places} />
|
||||
{:else}
|
||||
{#each groupedPlaces as placeGroup (placeGroup.id)}
|
||||
|
||||
Reference in New Issue
Block a user