mirror of
https://github.com/immich-app/immich.git
synced 2026-07-03 03:15:22 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4136f6487 | |||
| 2217d2e679 | |||
| 18fec31c1e | |||
| 57eee0148c | |||
| 880f8e2e4c | |||
| 3f67c400d2 | |||
| 5f5ce12041 | |||
| 1c3266e8f2 | |||
| 912484dcdd |
@@ -138,7 +138,9 @@ class LocalSyncService {
|
|||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||||
|
final getAlbumsTime = stopwatch.elapsedMilliseconds;
|
||||||
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||||
|
final getAllTime = stopwatch.elapsedMilliseconds;
|
||||||
|
|
||||||
await diffSortedLists(
|
await diffSortedLists(
|
||||||
dbAlbums,
|
dbAlbums,
|
||||||
@@ -148,10 +150,15 @@ class LocalSyncService {
|
|||||||
onlyFirst: removeAlbum,
|
onlyFirst: removeAlbum,
|
||||||
onlySecond: addAlbum,
|
onlySecond: addAlbum,
|
||||||
);
|
);
|
||||||
|
final diffTime = stopwatch.elapsedMilliseconds;
|
||||||
|
|
||||||
await _nativeSyncApi.checkpointSync();
|
await _nativeSyncApi.checkpointSync();
|
||||||
stopwatch.stop();
|
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) {
|
} on PlatformException catch (e, s) {
|
||||||
if (e.code == _kSyncCancelledCode) {
|
if (e.code == _kSyncCancelledCode) {
|
||||||
_log.warning("Full device sync cancelled");
|
_log.warning("Full device sync cancelled");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
@@ -66,15 +67,16 @@ class LogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLogRecord(LogRecord r) {
|
void _handleLogRecord(LogRecord r) {
|
||||||
|
final int isolateHash = Isolate.current.hashCode;
|
||||||
dPrint(
|
dPrint(
|
||||||
() =>
|
() =>
|
||||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
|
'[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
|
||||||
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
||||||
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final record = LogMessage(
|
final record = LogMessage(
|
||||||
message: r.message,
|
message: '[$isolateHash] ${r.message}',
|
||||||
level: r.level.toLogLevel(),
|
level: r.level.toLogLevel(),
|
||||||
createdAt: r.time,
|
createdAt: r.time,
|
||||||
logger: r.loggerName,
|
logger: r.loggerName,
|
||||||
|
|||||||
@@ -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/settings.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ class TimelineFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
|
static final Logger _log = Logger('TimelineService');
|
||||||
final TimelineAssetSource _assetSource;
|
final TimelineAssetSource _assetSource;
|
||||||
final TimelineBucketSource _bucketSource;
|
final TimelineBucketSource _bucketSource;
|
||||||
final TimelineOrigin origin;
|
final TimelineOrigin origin;
|
||||||
@@ -105,34 +107,49 @@ class TimelineService {
|
|||||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||||
|
|
||||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
_bucketSubscription = _bucketSource().listen(
|
||||||
_mutex.run(() async {
|
(buckets) {
|
||||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
_mutex.run(() async {
|
||||||
|
try {
|
||||||
|
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||||
|
|
||||||
if (totalAssets == 0) {
|
_log.info(
|
||||||
_bufferOffset = 0;
|
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
|
||||||
_buffer = [];
|
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
|
||||||
} else {
|
);
|
||||||
final int offset;
|
|
||||||
final int count;
|
if (totalAssets == 0) {
|
||||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
_bufferOffset = 0;
|
||||||
// we need to reset the buffer and load the first batch of assets.
|
_buffer = [];
|
||||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
} else {
|
||||||
offset = 0;
|
final int offset;
|
||||||
count = kTimelineAssetLoadBatchSize;
|
final int count;
|
||||||
} else {
|
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||||
offset = _bufferOffset;
|
// we need to reset the buffer and load the first batch of assets.
|
||||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
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;
|
},
|
||||||
}
|
onError: (Object error, StackTrace stack) {
|
||||||
|
_log.severe('[$origin] bucket stream errored', error, stack);
|
||||||
// change the state's total assets count only after the buffer is reloaded
|
},
|
||||||
_totalAssets = totalAssets;
|
);
|
||||||
EventStream.shared.emit(const TimelineReloadEvent());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||||
@@ -164,6 +181,13 @@ class TimelineService {
|
|||||||
_buffer = await _assetSource(start, len);
|
_buffer = await _assetSource(start, len);
|
||||||
_bufferOffset = start;
|
_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);
|
return getAssets(index, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MainTimelinePage extends ConsumerWidget {
|
class MainTimelinePage extends ConsumerWidget {
|
||||||
@@ -16,6 +17,9 @@ class MainTimelinePage extends ConsumerWidget {
|
|||||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
showStorageIndicator: true,
|
showStorageIndicator: true,
|
||||||
|
onRefresh: () {
|
||||||
|
Logger('MainTimelinePage').info('Pull-to-refresh triggered');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class FixedSegment extends Segment {
|
class FixedSegment extends Segment {
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
@@ -41,8 +42,7 @@ class FixedSegment extends Segment {
|
|||||||
required super.headerExtent,
|
required super.headerExtent,
|
||||||
required super.spacing,
|
required super.spacing,
|
||||||
required super.header,
|
required super.header,
|
||||||
}) : assert(tileHeight != 0),
|
}) : mainAxisExtend = tileHeight + spacing;
|
||||||
mainAxisExtend = tileHeight + spacing;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double indexToLayoutOffset(int index) {
|
double indexToLayoutOffset(int index) {
|
||||||
@@ -90,6 +90,7 @@ class FixedSegment extends Segment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FixedSegmentRow extends ConsumerWidget {
|
class _FixedSegmentRow extends ConsumerWidget {
|
||||||
|
static final Logger _log = Logger('TimelineRow');
|
||||||
final int assetIndex;
|
final int assetIndex;
|
||||||
final int assetCount;
|
final int assetCount;
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
@@ -109,8 +110,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
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(
|
return _buildAssetRow(
|
||||||
context,
|
context,
|
||||||
timelineService.getAssets(assetIndex, assetCount),
|
timelineService.getAssets(assetIndex, assetCount),
|
||||||
@@ -129,6 +142,13 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return _buildPlaceholder(context);
|
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);
|
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/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:intl/intl.dart' hide TextDirection;
|
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
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
/// for quick navigation of the BoxScrollView.
|
/// 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 {
|
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||||
|
static final Logger _log = Logger('Scrubber');
|
||||||
String? _lastLabel;
|
String? _lastLabel;
|
||||||
double _thumbTopOffset = 0.0;
|
double _thumbTopOffset = 0.0;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_log.info('Scrubber initState');
|
||||||
_isDragging = false;
|
_isDragging = false;
|
||||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||||
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
||||||
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||||
super.didUpdateWidget(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);
|
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||||
_monthCount = getMonthCount();
|
_monthCount = getMonthCount();
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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();
|
_thumbAnimationController.dispose();
|
||||||
_labelAnimationController.dispose();
|
_labelAnimationController.dispose();
|
||||||
_fadeOutTimer?.cancel();
|
_fadeOutTimer?.cancel();
|
||||||
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
|
_log.info('scrub dragStart');
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
_labelAnimationController.forward();
|
_labelAnimationController.forward();
|
||||||
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_scrubberHeight <= 0) {
|
if (_scrubberHeight <= 0) {
|
||||||
|
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
|
||||||
return;
|
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) {
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
_thumbAnimationController.forward();
|
_thumbAnimationController.forward();
|
||||||
}
|
}
|
||||||
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(DragEndDetails _) {
|
void _onDragEnd(DragEndDetails _) {
|
||||||
|
_log.info('scrub dragEnd -> setScrubbing(false)');
|
||||||
_labelAnimationController.reverse();
|
_labelAnimationController.reverse();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = false;
|
_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/presentation/widgets/timeline/segment.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class TimelineArgs {
|
class TimelineArgs {
|
||||||
final double maxWidth;
|
final double maxWidth;
|
||||||
@@ -71,14 +72,27 @@ class TimelineState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||||
|
static final Logger _log = Logger('TimelineState');
|
||||||
|
|
||||||
void setScrubbing(bool isScrubbing) {
|
void setScrubbing(bool isScrubbing) {
|
||||||
|
if (state.isScrubbing != isScrubbing) {
|
||||||
|
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
|
||||||
|
}
|
||||||
state = state.copyWith(isScrubbing: isScrubbing);
|
state = state.copyWith(isScrubbing: isScrubbing);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setScrolling(bool isScrolling) {
|
void setScrolling(bool isScrolling) {
|
||||||
|
if (state.isScrolling != isScrolling) {
|
||||||
|
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
|
||||||
|
}
|
||||||
state = state.copyWith(isScrolling: isScrolling);
|
state = state.copyWith(isScrolling: isScrolling);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _callSite() {
|
||||||
|
final frames = StackTrace.current.toString().split('\n');
|
||||||
|
return frames.length > 2 ? frames[2].trim() : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||||
}
|
}
|
||||||
@@ -86,16 +100,29 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
|||||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||||
// It should be used only after the timeline service and timeline args provider is overridden
|
// It should be used only after the timeline service and timeline args provider is overridden
|
||||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||||
|
final log = Logger('TimelineSegmentProvider');
|
||||||
|
|
||||||
final args = ref.watch(timelineArgsProvider);
|
final args = ref.watch(timelineArgsProvider);
|
||||||
final columnCount = args.columnCount;
|
final columnCount = args.columnCount;
|
||||||
final spacing = args.spacing;
|
final spacing = args.spacing;
|
||||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||||
|
log.info(
|
||||||
|
'timelineSegmentProvider: maxWidth=${args.maxWidth}, spacing=$spacing, availableTileWidth=$availableTileWidth, columnCount=$columnCount',
|
||||||
|
);
|
||||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||||
|
log.info(
|
||||||
|
'timelineSegmentProvider: tileExtent=$tileExtent (availableTileWidth=$availableTileWidth / columnCount=$columnCount)',
|
||||||
|
);
|
||||||
|
|
||||||
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||||
|
|
||||||
final timelineService = ref.watch(timelineServiceProvider);
|
final timelineService = ref.watch(timelineServiceProvider);
|
||||||
yield* timelineService.watchBuckets().map((buckets) {
|
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(
|
return FixedSegmentBuilder(
|
||||||
buckets: buckets,
|
buckets: buckets,
|
||||||
tileHeight: tileExtent,
|
tileHeight: tileExtent,
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ 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/immich_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/mesmerizing_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:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class Timeline extends StatelessWidget {
|
class Timeline extends StatefulWidget {
|
||||||
const Timeline({
|
const Timeline({
|
||||||
super.key,
|
super.key,
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
@@ -45,6 +46,7 @@ class Timeline extends StatelessWidget {
|
|||||||
this.readOnly = false,
|
this.readOnly = false,
|
||||||
this.persistentBottomBar = false,
|
this.persistentBottomBar = false,
|
||||||
this.loadingWidget,
|
this.loadingWidget,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -60,35 +62,56 @@ class Timeline extends StatelessWidget {
|
|||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final bool persistentBottomBar;
|
final bool persistentBottomBar;
|
||||||
final Widget? loadingWidget;
|
final Widget? loadingWidget;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Timeline> createState() => _TimelineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineState extends State<Timeline> {
|
||||||
|
bool _rebuildTrigger = false;
|
||||||
|
|
||||||
|
Future<void> _handleRefresh() async {
|
||||||
|
if (widget.onRefresh == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _rebuildTrigger = !_rebuildTrigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) => ProviderScope(
|
||||||
|
key: ValueKey(_rebuildTrigger),
|
||||||
overrides: [
|
overrides: [
|
||||||
timelineArgsProvider.overrideWith(
|
timelineArgsProvider.overrideWith(
|
||||||
(ref) => TimelineArgs(
|
(ref) => TimelineArgs(
|
||||||
maxWidth: constraints.maxWidth,
|
maxWidth: constraints.maxWidth,
|
||||||
maxHeight: constraints.maxHeight,
|
maxHeight: constraints.maxHeight,
|
||||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
withStack: withStack,
|
withStack: widget.withStack,
|
||||||
groupBy: groupBy,
|
groupBy: widget.groupBy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||||
],
|
],
|
||||||
child: _SliverTimeline(
|
child: _SliverTimeline(
|
||||||
topSliverWidget: topSliverWidget,
|
topSliverWidget: widget.topSliverWidget,
|
||||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
topSliverWidgetHeight: widget.topSliverWidgetHeight,
|
||||||
bottomSliverWidget: bottomSliverWidget,
|
bottomSliverWidget: widget.bottomSliverWidget,
|
||||||
appBar: appBar,
|
appBar: widget.appBar,
|
||||||
bottomSheet: bottomSheet,
|
bottomSheet: widget.bottomSheet,
|
||||||
withScrubber: withScrubber,
|
withScrubber: widget.withScrubber,
|
||||||
persistentBottomBar: persistentBottomBar,
|
persistentBottomBar: widget.persistentBottomBar,
|
||||||
snapToMonth: snapToMonth,
|
snapToMonth: widget.snapToMonth,
|
||||||
maxWidth: constraints.maxWidth,
|
maxWidth: constraints.maxWidth,
|
||||||
loadingWidget: loadingWidget,
|
loadingWidget: widget.loadingWidget,
|
||||||
|
onRefresh: widget.onRefresh == null ? null : _handleRefresh,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -118,6 +141,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
this.snapToMonth = true,
|
this.snapToMonth = true,
|
||||||
this.maxWidth,
|
this.maxWidth,
|
||||||
this.loadingWidget,
|
this.loadingWidget,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -130,12 +154,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
final bool snapToMonth;
|
final bool snapToMonth;
|
||||||
final double? maxWidth;
|
final double? maxWidth;
|
||||||
final Widget? loadingWidget;
|
final Widget? loadingWidget;
|
||||||
|
final Future<void> Function()? onRefresh;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
|
static final Logger _log = Logger('Timeline');
|
||||||
late final ScrollController _scrollController;
|
late final ScrollController _scrollController;
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
|
|
||||||
@@ -153,6 +179,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_log.info('SliverTimeline initState');
|
||||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||||
|
|
||||||
@@ -179,6 +206,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(Event event) {
|
void _onEvent(Event event) {
|
||||||
|
_log.info('event ${event.runtimeType}');
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case ScrollToTopEvent():
|
case ScrollToTopEvent():
|
||||||
{
|
{
|
||||||
@@ -186,7 +214,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
timelineState.setScrubbing(true);
|
timelineState.setScrubbing(true);
|
||||||
_scrollController
|
_scrollController
|
||||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
.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:
|
case ScrollToDateEvent scrollToDateEvent:
|
||||||
@@ -246,6 +277,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_log.info('SliverTimeline dispose');
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -286,8 +318,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
)
|
)
|
||||||
.whenComplete(() => timelineState.setScrubbing(false));
|
.whenComplete(() {
|
||||||
|
_log.info('ScrollToDate animation done -> setScrubbing(false)');
|
||||||
|
timelineState.setScrubbing(false);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
|
||||||
timelineState.setScrubbing(false);
|
timelineState.setScrubbing(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -425,7 +461,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final Widget timeline;
|
Widget timeline;
|
||||||
if (widget.withScrubber) {
|
if (widget.withScrubber) {
|
||||||
timeline = Scrubber(
|
timeline = Scrubber(
|
||||||
snapToMonth: widget.snapToMonth,
|
snapToMonth: widget.snapToMonth,
|
||||||
@@ -440,6 +476,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
} else {
|
} else {
|
||||||
timeline = grid;
|
timeline = grid;
|
||||||
}
|
}
|
||||||
|
if (widget.onRefresh != null) {
|
||||||
|
timeline = RefreshIndicator(onRefresh: widget.onRefresh!, child: timeline);
|
||||||
|
}
|
||||||
|
|
||||||
return RawGestureDetector(
|
return RawGestureDetector(
|
||||||
gestures: {
|
gestures: {
|
||||||
|
|||||||
@@ -101,8 +101,20 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelSync() async {
|
||||||
|
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSync = _ref.read(nativeSyncApiProvider);
|
||||||
|
await Future.wait([
|
||||||
|
nativeSync.cancelSync(),
|
||||||
|
nativeSync.cancelHashing(),
|
||||||
|
backgroundManager.cancel(),
|
||||||
|
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleBetaTimelineResume() async {
|
Future<void> _handleBetaTimelineResume() async {
|
||||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
||||||
|
_log.info("Handling beta timeline resume");
|
||||||
|
await _cancelSync();
|
||||||
|
|
||||||
// Give isolates time to complete any ongoing database transactions
|
// Give isolates time to complete any ongoing database transactions
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
@@ -196,6 +208,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
Future<void> _performPause() {
|
Future<void> _performPause() {
|
||||||
if (_ref.read(authProvider).isAuthenticated) {
|
if (_ref.read(authProvider).isAuthenticated) {
|
||||||
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||||
|
unawaited(_cancelSync());
|
||||||
|
|
||||||
_ref.read(websocketProvider.notifier).disconnect();
|
_ref.read(websocketProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('TimelineProvider');
|
||||||
|
|
||||||
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||||
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
|
|||||||
(ref) {
|
(ref) {
|
||||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
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;
|
return timelineService;
|
||||||
},
|
},
|
||||||
// Empty dependencies to inform the framework that this provider
|
// Empty dependencies to inform the framework that this provider
|
||||||
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
|||||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||||
if (currentUserId == null) {
|
if (currentUserId == null) {
|
||||||
|
_log.info('timelineUsers: currentUserId=null -> []');
|
||||||
return Stream.value([]);
|
return Stream.value([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
|
||||||
|
_log.info('timelineUsers emission: $users');
|
||||||
|
return users;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -366,18 +366,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
|
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.34.0"
|
||||||
drift_dev:
|
drift_dev:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: drift_dev
|
name: drift_dev
|
||||||
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
|
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.34.0"
|
||||||
drift_sqlite_async:
|
drift_sqlite_async:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ dependencies:
|
|||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
device_info_plus: ^12.4.0
|
device_info_plus: ^12.4.0
|
||||||
diacritic: ^0.1.6
|
diacritic: ^0.1.6
|
||||||
drift: ^2.32.1
|
drift: ^2.34.0
|
||||||
drift_sqlite_async: 0.3.1
|
drift_sqlite_async: 0.3.1
|
||||||
dynamic_color: ^1.8.1
|
dynamic_color: ^1.8.1
|
||||||
easy_localization: ^3.0.8
|
easy_localization: ^3.0.8
|
||||||
@@ -96,7 +96,7 @@ dev_dependencies:
|
|||||||
auto_route_generator: ^10.5.0
|
auto_route_generator: ^10.5.0
|
||||||
build_runner: ^2.13.1
|
build_runner: ^2.13.1
|
||||||
# Drift generator
|
# Drift generator
|
||||||
drift_dev: ^2.32.1
|
drift_dev: ^2.34.0
|
||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
file: ^7.0.1 # for MemoryFileSystem
|
file: ^7.0.1 # for MemoryFileSystem
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|||||||
Reference in New Issue
Block a user