Compare commits

..

5 Commits

Author SHA1 Message Date
Alex 067df1d635 Merge branch 'main' into debug/blank-timeline 2026-06-22 09:31:20 -05:00
Alex 0dc029d616 Merge branch 'main' of github.com:immich-app/immich into debug/blank-timeline 2026-06-17 14:10:05 -05:00
shenlong-tanwen 430e1fed7d debug: full local sync 2026-06-17 22:48:25 +05:30
shenlong-tanwen 9c698d6694 debug: more logs
# Conflicts:
#	mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart
2026-06-17 22:12:45 +05:30
shenlong-tanwen 2cc920ccdc debug: blank timeline rendering 2026-06-17 22:11:52 +05:30
50 changed files with 369 additions and 1011 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'zulu'
java-version: '17'
+1 -2
View File
@@ -25,12 +25,11 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
review: false
check-mobile-patches:
runs-on: ubuntu-latest
+2 -2
View File
@@ -406,7 +406,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -483,7 +483,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -1548,7 +1548,7 @@
"map_location_picker_page_use_location": "Use this location",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
@@ -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,7 @@ 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';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -90,6 +91,7 @@ class TimelineFactory {
}
class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
@@ -105,34 +107,49 @@ 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 = _bucketSource().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;
@@ -164,6 +181,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);
}
-1
View File
@@ -263,7 +263,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
child: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: true,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
abstract class BaseAction {
final IconData icon;
const BaseAction({required this.icon});
String label(BuildContext context);
bool isVisible(BuildContext context, WidgetRef ref);
Future<void> onAction(BuildContext context, WidgetRef ref);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final List<T> assets;
const AssetAction({required super.icon, required this.assets});
List<T> assetsForAction(BuildContext context, WidgetRef ref);
}
@@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
abstract class BaseActionWidget extends ConsumerWidget {
final BaseAction action;
final void Function(BuildContext _, WidgetRef _)? postAction;
const BaseActionWidget({super.key, required this.action, this.postAction});
Widget buildAction(BuildContext context, Future<void> Function() onPressed);
Future<void> _onPressed(BuildContext context, WidgetRef ref) async {
try {
await action.onAction(context, ref);
} catch (error, stackTrace) {
handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
if (context.mounted) {
postAction?.call(context, ref);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible(context, ref)) {
return const SizedBox.shrink();
}
return buildAction(context, () => _onPressed(context, ref));
}
}
class ActionIconButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichIconButton(icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionColumnButtonWidget extends BaseActionWidget {
const ActionColumnButtonWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
class ActionMenuItemWidget extends BaseActionWidget {
const ActionMenuItemWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
@@ -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;
});
});
-61
View File
@@ -1,61 +0,0 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
final clean = trace.foldFrames(
(frame) => frame.package == 'flutter' || frame.package == 'flutter_test' || frame.isCore,
terse: true,
);
stackTrace = clean.toString();
}
dPrint(
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
}
snackbar.error(message);
}
@visibleForTesting
String? serverErrorMessage(Object error) {
if (error is! ApiException || error.innerException != null || error.message == null) {
return null;
}
try {
final body = jsonDecode(error.message!);
if (body is Map && body['message'] != null) {
final message = body['message'];
return message is List ? message.join(', ') : message.toString();
}
} catch (_) {
// The body was not JSON; fall back to the raw payload below.
}
return error.message;
}
@visibleForTesting
bool isConnectionError(Object error) => error is ApiException && error.innerException != null;
+3 -12
View File
@@ -1,16 +1,7 @@
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
// Add latest compat info up top
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
}
return null;
+12 -4
View File
@@ -26,7 +26,6 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -89,9 +88,18 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
-3
View File
@@ -1,15 +1,12 @@
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/menu_item.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/components/url_input.dart';
export 'src/constants.dart';
export 'src/snackbar.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';
@@ -1,13 +0,0 @@
import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
@override
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
}
@@ -16,9 +16,10 @@ class ImmichCloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) => ImmichIconButton(
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
key: key,
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -1,78 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
final bool? loading;
const ImmichColumnButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
this.loading,
});
@override
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
tapTargetSize: .shrinkWrap,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
),
child: ConstrainedBox(
constraints: const .new(maxWidth: 90),
child: Column(
mainAxisSize: .min,
children: [
_isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: Icon(widget.icon, size: ImmichIconSize.md),
const SizedBox(height: ImmichSpacing.sm),
Text(
widget.label,
maxLines: 2,
textAlign: .center,
overflow: .ellipsis,
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
),
],
),
),
);
}
}
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: .filled,
variant: ImmichVariant.filled,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final span = widget.spanBuilder?.call(tag);
final style = span?.style ?? _defaultTextStyle(tag);
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (span?.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
@@ -114,12 +114,19 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
@override
Widget build(BuildContext context) {
@@ -1,80 +1,54 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatefulWidget {
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final VoidCallback onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
final bool? loading;
const ImmichIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color = .primary,
this.variant = .filled,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.disabled = false,
this.loading,
});
@override
State<ImmichIconButton> createState() => _ImmichIconButtonState();
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
.ghost => Colors.transparent,
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground =
context.colorOverride ??
switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.onPrimary,
.secondary => colorScheme.onSecondary,
},
.ghost => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
return IconButton(
icon: _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.sm,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
icon: Icon(icon),
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
);
}
}
@@ -1,100 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichMenu extends StatefulWidget {
final List<Widget> children;
final MenuAnchorChildBuilder builder;
final MenuStyle? style;
final bool consumeOutsideTap;
final Widget? child;
const ImmichMenu({
super.key,
required this.children,
required this.builder,
this.style,
this.consumeOutsideTap = false,
this.child,
});
@override
State<ImmichMenu> createState() => _ImmichMenuState();
}
class _ImmichMenuState extends State<ImmichMenu> {
final _controller = MenuController();
@override
Widget build(BuildContext context) {
return _ImmichMenuScope(
controller: _controller,
child: MenuAnchor(
controller: _controller,
style: widget.style,
consumeOutsideTap: widget.consumeOutsideTap,
menuChildren: widget.children,
builder: widget.builder,
child: widget.child,
),
);
}
}
class _ImmichMenuScope extends InheritedWidget {
final MenuController controller;
const _ImmichMenuScope({required this.controller, required super.child});
static MenuController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
@override
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
}
class ImmichMenuItem extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
const ImmichMenuItem({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
});
@override
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
}
class _ImmichMenuItemState extends State<ImmichMenuItem> {
Future<void> _onPressed(MenuController? controller) async {
try {
await widget.onPressed();
} finally {
controller?.close();
}
}
@override
Widget build(BuildContext context) {
final controller = _ImmichMenuScope.maybeOf(context);
return MenuItemButton(
onPressed: widget.disabled ? null : () => _onPressed(controller),
closeOnActivate: controller == null,
style: MenuItemButton.styleFrom(
foregroundColor: context.colorOverride,
alignment: .centerLeft,
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
),
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
);
}
}
@@ -52,6 +52,7 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -1,72 +1,85 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
class ImmichTextButton extends StatefulWidget {
class ImmichTextButton extends StatelessWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
final bool? loading;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = .filled,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.expanded = true,
this.loading = false,
this.disabled = false,
this.loading,
});
@override
State<ImmichTextButton> createState() => _ImmichTextButtonState();
}
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
}
}
@override
Widget build(BuildContext context) {
final Widget? icon = _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: widget.icon != null
? Icon(widget.icon, fontWeight: .w600)
: null;
final label = Text(
widget.labelText,
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {
final button = _buildButton(variant);
if (expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
-5
View File
@@ -1,11 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
extension ColorHelper on BuildContext {
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
}
@@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/column_button.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'ColumnButton', name: 'Default')
Widget previewColumnButtonDefault() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
],
);
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
Widget previewColumnButtonLoading() => ImmichColumnButton(
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
icon: Icons.download,
label: 'Download',
);
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
Widget previewColumnButtonDisabled() =>
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
@@ -1,19 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/menu_item.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'MenuItem', name: 'Default')
Widget previewMenuItemDefault() => const Column(
mainAxisSize: MainAxisSize.min,
children: [
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
],
);
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
Widget previewMenuItemDisabled() =>
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
@@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/previews.dart';
import 'package:immich_ui/src/snackbar.dart';
@ImmichPreview(group: 'Snackbar', name: 'Types')
Widget previewSnackbarTypes() => const _SnackbarDemo();
class _SnackbarDemo extends StatelessWidget {
const _SnackbarDemo();
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
key: scaffoldMessengerKey,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Wrap(
spacing: ImmichSpacing.md,
runSpacing: ImmichSpacing.md,
children: [
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
],
),
),
),
);
}
}
@@ -15,6 +15,16 @@ Widget previewTextButtonVariants() => const Wrap(
],
);
@ImmichPreview(group: 'TextButton', name: 'Colors')
Widget previewTextButtonColors() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
],
);
@ImmichPreview(group: 'TextButton', name: 'With Icons')
Widget previewTextButtonWithIcons() => const Wrap(
spacing: 12,
@@ -32,11 +42,7 @@ Widget previewTextButtonWithIcons() => const Wrap(
);
@ImmichPreview(group: 'TextButton', name: 'Loading')
Widget previewTextButtonLoading() => ImmichTextButton(
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
labelText: 'Click me',
expanded: false,
);
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
@ImmichPreview(group: 'TextButton', name: 'Disabled')
Widget previewTextButtonDisabled() => const Wrap(
@@ -53,3 +59,30 @@ Widget previewTextButtonDisabled() => const Wrap(
),
],
);
class _PreviewLoadingDemo extends StatefulWidget {
const _PreviewLoadingDemo();
@override
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
}
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return ImmichTextButton(
onPressed: () async {
setState(() => _isLoading = true);
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() => _isLoading = false);
}
},
labelText: _isLoading ? 'Loading...' : 'Click Me',
loading: _isLoading,
expanded: false,
);
}
}
-58
View File
@@ -1,58 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
.info => (Icons.info_rounded, colors.info, colors.onInfo),
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
.error => (Icons.warning_rounded, colors.error, colors.onError),
};
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
Icon(icon, color: foreground, size: ImmichIconSize.sm),
const SizedBox(width: ImmichSpacing.md),
Expanded(
child: Text(
message,
maxLines: 2,
overflow: .ellipsis,
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
),
),
],
),
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();
+2 -74
View File
@@ -1,8 +1,5 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:material_color_utilities/blend/blend.dart';
import 'package:material_color_utilities/hct/hct.dart';
import 'package:material_color_utilities/palettes/tonal_palette.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
@@ -14,7 +11,6 @@ class ImmichThemeProvider extends StatelessWidget {
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
extensions: [ImmichColors.harmonized(colorScheme)],
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
@@ -23,8 +19,8 @@ class ImmichThemeProvider extends StatelessWidget {
final color = states.contains(WidgetState.error)
? colorScheme.error
: states.contains(WidgetState.focused)
? colorScheme.primary
: colorScheme.outline;
? colorScheme.primary
: colorScheme.outline;
return OutlineInputBorder(
borderSide: BorderSide(color: color),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
@@ -42,71 +38,3 @@ class ImmichThemeProvider extends StatelessWidget {
);
}
}
class ImmichColors extends ThemeExtension<ImmichColors> {
final Color info;
final Color onInfo;
final Color success;
final Color onSuccess;
final Color error;
final Color onError;
const ImmichColors({
required this.info,
required this.onInfo,
required this.success,
required this.onSuccess,
required this.error,
required this.onError,
});
factory ImmichColors.harmonized(ColorScheme scheme) {
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
return ImmichColors(
info: info,
onInfo: onInfo,
success: success,
onSuccess: onSuccess,
error: error,
onError: onError,
);
}
@override
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
return ImmichColors(
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
error: error ?? this.error,
onError: onError ?? this.onError,
);
}
@override
ImmichColors lerp(ImmichColors? other, double t) {
if (other == null) {
return this;
}
return ImmichColors(
info: Color.lerp(info, other.info, t)!,
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
error: Color.lerp(error, other.error, t)!,
onError: Color.lerp(onError, other.onError, t)!,
);
}
}
extension on ColorScheme {
(Color container, Color onContainer) harmonized(Color seed) {
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
final tones = TonalPalette.of(hct.hue, hct.chroma);
final isDark = brightness == Brightness.dark;
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
}
}
+8 -4
View File
@@ -1,5 +1,9 @@
enum ImmichVariant { filled, ghost }
enum ImmichVariant {
filled,
ghost,
}
enum ImmichColor { primary, secondary }
enum SnackbarType { info, success, error }
enum ImmichColor {
primary,
secondary,
}
+1 -1
View File
@@ -92,7 +92,7 @@ packages:
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: "direct main"
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
-1
View File
@@ -7,7 +7,6 @@ environment:
dependencies:
flutter:
sdk: flutter
material_color_utilities: any
dev_dependencies:
flutter_test:
@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'test_utils.dart';
void main() {
group('ImmichColorOverride', () {
testWidgets('exposes the override color to descendants', (tester) async {
Color? captured;
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
),
);
expect(captured, Colors.green);
});
testWidgets('maybeOf returns null when there is no override', (tester) async {
Color? captured = Colors.black;
await tester.pumpTestWidget(
Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
);
expect(captured, isNull);
});
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
});
});
}
@@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
import 'test_utils.dart';
void main() {
group('SnackbarManager', () {
testWidgets('shows the message', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.success('hello');
await tester.pump();
expect(find.text('hello'), findsOneWidget);
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('replaces the current snackbar', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.info('first');
await tester.pump();
snackbar.error('second');
await tester.pump();
expect(find.text('first'), findsNothing);
expect(find.text('second'), findsOneWidget);
});
testWidgets('no-ops when the messenger is unmounted', (tester) async {
expect(snackbar.show('x', .info), isNull);
});
});
}
+2 -7
View File
@@ -1,14 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
extension WidgetTesterExtension on WidgetTester {
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
Future<void> pumpTestWidget(Widget widget) {
return pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: Scaffold(body: widget),
),
);
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
}
}
@@ -1,47 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
test('getVersionCompatibilityMessage', () {
String? result;
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
});
}
+7 -7
View File
@@ -88,7 +88,7 @@ from
where
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" > $5
and "album_asset"."updateId" >= $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -202,7 +202,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" > $3
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -297,7 +297,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" > $3
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -349,7 +349,7 @@ from
where
"album_user"."updateId" < $1
and "album_user"."updateId" <= $2
and "album_user"."updateId" > $3
and "album_user"."updateId" >= $3
and "albumId" = $4
order by
"album_user"."updateId" asc
@@ -810,7 +810,7 @@ from
where
"asset"."updateId" < $2
and "asset"."updateId" <= $3
and "asset"."updateId" > $4
and "asset"."updateId" >= $4
and "ownerId" = $5
order by
"asset"."updateId" asc
@@ -908,7 +908,7 @@ from
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" <= $2
and "asset_exif"."updateId" > $3
and "asset_exif"."updateId" >= $3
and "asset"."ownerId" = $4
order by
"asset_exif"."updateId" asc
@@ -997,7 +997,7 @@ from
where
"stack"."updateId" < $1
and "stack"."updateId" <= $2
and "stack"."updateId" > $3
and "stack"."updateId" >= $3
and "ownerId" = $4
order by
"stack"."updateId" asc
+1 -1
View File
@@ -106,7 +106,7 @@ export class BaseSync {
.selectFrom(table(t).as(t))
.where(updateIdRef, '<', nowId)
.where(updateIdRef, '<=', beforeUpdateId)
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!))
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>=', afterUpdateId!))
.orderBy(updateIdRef, 'asc');
}
@@ -155,57 +155,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: sharedAsset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: sharedAsset2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: sharedAlbum } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset1.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset2.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: ownedAsset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album: ownedAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: ownedAlbum.id, assetId: ownedAsset.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
await ctx.syncAckAll(auth, setupResponse);
// share album to trigger backfill
await ctx.newAlbumUser({ albumId: sharedAlbum.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response1 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response1).toEqual([
// receive both
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset1.id } }),
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
@@ -279,68 +279,6 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: partnerAsset1 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
const { asset: partnerAsset2 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(setupResponse).toEqual([
expect.objectContaining({
data: expect.objectContaining({ id: initialAsset.id }),
type: SyncEntityType.PartnerAssetV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, setupResponse);
// partner share to trigger backfill
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const response1 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response1).toEqual([
// receive both
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset1.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
+1 -3
View File
@@ -159,9 +159,7 @@
}
.text-white-shadow {
text-shadow:
0 0 4px rgba(0, 0, 0, 0.9),
0 1px 3px rgba(0, 0, 0, 0.8);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.icon-white-drop-shadow {
@@ -23,7 +23,7 @@
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -310,13 +310,14 @@
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
<Link
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
class="text-primary"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</Link>
</a>
</div>
{/snippet}
</Map>
@@ -342,7 +342,7 @@
{#if !!assetOwner}
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
{assetOwner.name}
</p>
</div>
@@ -38,6 +38,7 @@
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
@@ -342,6 +343,7 @@
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
@@ -399,13 +401,13 @@
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_image', {
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
@@ -413,7 +415,7 @@
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup({ marker: asMarker(feature) })}
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
@@ -169,9 +169,7 @@
preload={false}
/>
{#if person.name}
<span
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
>
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
{person.name}
</span>
{/if}