mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: memories in new timeline (#19720)
* feat: memories sliver * memories lane * display and show memory * fix: get correct memories * naming * pr feedback * use equalsValue for visibility --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
181efb9010
commit
4a2cf28882
@ -1,6 +1,10 @@
|
|||||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
enum MemoryTypeEnum {
|
enum MemoryTypeEnum {
|
||||||
// do not change this order!
|
// do not change this order!
|
||||||
onThisDay,
|
onThisDay,
|
||||||
@ -53,7 +57,7 @@ class MemoryData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model for a memory stored in the server
|
// Model for a memory stored in the server
|
||||||
class Memory {
|
class DriftMemory {
|
||||||
final String id;
|
final String id;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
@ -68,8 +72,9 @@ class Memory {
|
|||||||
final DateTime? seenAt;
|
final DateTime? seenAt;
|
||||||
final DateTime? showAt;
|
final DateTime? showAt;
|
||||||
final DateTime? hideAt;
|
final DateTime? hideAt;
|
||||||
|
final List<RemoteAsset> assets;
|
||||||
|
|
||||||
const Memory({
|
const DriftMemory({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
@ -82,9 +87,10 @@ class Memory {
|
|||||||
this.seenAt,
|
this.seenAt,
|
||||||
this.showAt,
|
this.showAt,
|
||||||
this.hideAt,
|
this.hideAt,
|
||||||
|
required this.assets,
|
||||||
});
|
});
|
||||||
|
|
||||||
Memory copyWith({
|
DriftMemory copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
@ -97,8 +103,9 @@ class Memory {
|
|||||||
DateTime? seenAt,
|
DateTime? seenAt,
|
||||||
DateTime? showAt,
|
DateTime? showAt,
|
||||||
DateTime? hideAt,
|
DateTime? hideAt,
|
||||||
|
List<RemoteAsset>? assets,
|
||||||
}) {
|
}) {
|
||||||
return Memory(
|
return DriftMemory(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
@ -111,64 +118,19 @@ class Memory {
|
|||||||
seenAt: seenAt ?? this.seenAt,
|
seenAt: seenAt ?? this.seenAt,
|
||||||
showAt: showAt ?? this.showAt,
|
showAt: showAt ?? this.showAt,
|
||||||
hideAt: hideAt ?? this.hideAt,
|
hideAt: hideAt ?? this.hideAt,
|
||||||
|
assets: assets ?? this.assets,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
return <String, dynamic>{
|
|
||||||
'id': id,
|
|
||||||
'createdAt': createdAt.millisecondsSinceEpoch,
|
|
||||||
'updatedAt': updatedAt.millisecondsSinceEpoch,
|
|
||||||
'deletedAt': deletedAt?.millisecondsSinceEpoch,
|
|
||||||
'ownerId': ownerId,
|
|
||||||
'type': type.index,
|
|
||||||
'data': data.toMap(),
|
|
||||||
'isSaved': isSaved,
|
|
||||||
'memoryAt': memoryAt.millisecondsSinceEpoch,
|
|
||||||
'seenAt': seenAt?.millisecondsSinceEpoch,
|
|
||||||
'showAt': showAt?.millisecondsSinceEpoch,
|
|
||||||
'hideAt': hideAt?.millisecondsSinceEpoch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory Memory.fromMap(Map<String, dynamic> map) {
|
|
||||||
return Memory(
|
|
||||||
id: map['id'] as String,
|
|
||||||
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int),
|
|
||||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int),
|
|
||||||
deletedAt: map['deletedAt'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['deletedAt'] as int)
|
|
||||||
: null,
|
|
||||||
ownerId: map['ownerId'] as String,
|
|
||||||
type: MemoryTypeEnum.values[map['type'] as int],
|
|
||||||
data: MemoryData.fromMap(map['data'] as Map<String, dynamic>),
|
|
||||||
isSaved: map['isSaved'] as bool,
|
|
||||||
memoryAt: DateTime.fromMillisecondsSinceEpoch(map['memoryAt'] as int),
|
|
||||||
seenAt: map['seenAt'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['seenAt'] as int)
|
|
||||||
: null,
|
|
||||||
showAt: map['showAt'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['showAt'] as int)
|
|
||||||
: null,
|
|
||||||
hideAt: map['hideAt'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(map['hideAt'] as int)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String toJson() => json.encode(toMap());
|
|
||||||
|
|
||||||
factory Memory.fromJson(String source) =>
|
|
||||||
Memory.fromMap(json.decode(source) as Map<String, dynamic>);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt)';
|
return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant Memory other) {
|
bool operator ==(covariant DriftMemory other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
return other.id == id &&
|
return other.id == id &&
|
||||||
other.createdAt == createdAt &&
|
other.createdAt == createdAt &&
|
||||||
@ -181,7 +143,8 @@ class Memory {
|
|||||||
other.memoryAt == memoryAt &&
|
other.memoryAt == memoryAt &&
|
||||||
other.seenAt == seenAt &&
|
other.seenAt == seenAt &&
|
||||||
other.showAt == showAt &&
|
other.showAt == showAt &&
|
||||||
other.hideAt == hideAt;
|
other.hideAt == hideAt &&
|
||||||
|
listEquals(other.assets, assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -197,6 +160,7 @@ class Memory {
|
|||||||
memoryAt.hashCode ^
|
memoryAt.hashCode ^
|
||||||
seenAt.hashCode ^
|
seenAt.hashCode ^
|
||||||
showAt.hashCode ^
|
showAt.hashCode ^
|
||||||
hideAt.hashCode;
|
hideAt.hashCode ^
|
||||||
|
assets.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
15
mobile/lib/domain/services/memory.service.dart
Normal file
15
mobile/lib/domain/services/memory.service.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class DriftMemoryService {
|
||||||
|
final log = Logger("DriftMemoryService");
|
||||||
|
|
||||||
|
final DriftMemoryRepository _repository;
|
||||||
|
|
||||||
|
DriftMemoryService(this._repository);
|
||||||
|
|
||||||
|
Future<List<DriftMemory>> getMemoryLane(String ownerId) {
|
||||||
|
return _repository.getAll(ownerId);
|
||||||
|
}
|
||||||
|
}
|
@ -1,25 +1,68 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
class DriftMemoryRepository extends DriftDatabaseRepository {
|
class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftMemoryRepository(this._db) : super(_db);
|
const DriftMemoryRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Future<List<Memory>> getAll(String userId) {
|
Future<List<DriftMemory>> getAll(String ownerId) async {
|
||||||
final query = _db.memoryEntity.select()
|
final now = DateTime.now();
|
||||||
..where((e) => e.ownerId.equals(userId));
|
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||||
|
|
||||||
return query.map((memory) {
|
final query = _db.select(_db.memoryEntity).join([
|
||||||
return memory.toDto();
|
leftOuterJoin(
|
||||||
}).get();
|
_db.memoryAssetEntity,
|
||||||
|
_db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id),
|
||||||
|
),
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.remoteAssetEntity,
|
||||||
|
_db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) &
|
||||||
|
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||||
|
_db.remoteAssetEntity.visibility
|
||||||
|
.equalsValue(AssetVisibility.timeline),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..where(_db.memoryEntity.ownerId.equals(ownerId))
|
||||||
|
..where(_db.memoryEntity.deletedAt.isNull())
|
||||||
|
..where(
|
||||||
|
_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc),
|
||||||
|
)
|
||||||
|
..where(
|
||||||
|
_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc),
|
||||||
|
)
|
||||||
|
..orderBy([
|
||||||
|
OrderingTerm.desc(_db.memoryEntity.memoryAt),
|
||||||
|
OrderingTerm.asc(_db.remoteAssetEntity.createdAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final rows = await query.get();
|
||||||
|
|
||||||
|
final Map<String, DriftMemory> memoriesMap = {};
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
final memory = row.readTable(_db.memoryEntity);
|
||||||
|
final asset = row.readTable(_db.remoteAssetEntity);
|
||||||
|
|
||||||
|
final existingMemory = memoriesMap[memory.id];
|
||||||
|
if (existingMemory != null) {
|
||||||
|
existingMemory.assets.add(asset.toDto());
|
||||||
|
} else {
|
||||||
|
final assets = [asset.toDto()];
|
||||||
|
memoriesMap[memory.id] = memory.toDto().copyWith(assets: assets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoriesMap.values.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on MemoryEntityData {
|
extension on MemoryEntityData {
|
||||||
Memory toDto() {
|
DriftMemory toDto() {
|
||||||
return Memory(
|
return DriftMemory(
|
||||||
id: id,
|
id: id,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
@ -32,6 +75,7 @@ extension on MemoryEntityData {
|
|||||||
seenAt: seenAt,
|
seenAt: seenAt,
|
||||||
showAt: showAt,
|
showAt: showAt,
|
||||||
hideAt: hideAt,
|
hideAt: hideAt,
|
||||||
|
assets: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MainTimelinePage extends ConsumerWidget {
|
class MainTimelinePage extends ConsumerWidget {
|
||||||
@ -9,6 +11,22 @@ class MainTimelinePage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const Timeline();
|
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
||||||
|
|
||||||
|
return memoryLaneProvider.when(
|
||||||
|
data: (memories) {
|
||||||
|
return memories.isEmpty
|
||||||
|
? const Timeline()
|
||||||
|
: Timeline(
|
||||||
|
topSliverWidget: SliverToBoxAdapter(
|
||||||
|
key: Key('memory-lane-${memories.first.assets.first.id}'),
|
||||||
|
child: DriftMemoryLane(memories: memories),
|
||||||
|
),
|
||||||
|
topSliverWidgetHeight: 200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Timeline(),
|
||||||
|
error: (error, stackTrace) => const Timeline(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
394
mobile/lib/presentation/pages/drift_memory.page.dart
Normal file
394
mobile/lib/presentation/pages/drift_memory.page.dart
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||||
|
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Expects [currentAssetNotifier] to be set before navigating to this page
|
||||||
|
@RoutePage()
|
||||||
|
class DriftMemoryPage extends HookConsumerWidget {
|
||||||
|
final List<DriftMemory> memories;
|
||||||
|
final int memoryIndex;
|
||||||
|
|
||||||
|
const DriftMemoryPage({
|
||||||
|
required this.memories,
|
||||||
|
required this.memoryIndex,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentMemory = useState(memories[memoryIndex]);
|
||||||
|
final currentAssetPage = useState(0);
|
||||||
|
final currentMemoryIndex = useState(memoryIndex);
|
||||||
|
final assetProgress = useState(
|
||||||
|
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
|
||||||
|
);
|
||||||
|
const bgColor = Colors.black;
|
||||||
|
final currentAsset = useState<RemoteAsset?>(null);
|
||||||
|
|
||||||
|
/// The list of all of the asset page controllers
|
||||||
|
final memoryAssetPageControllers =
|
||||||
|
List.generate(memories.length, (i) => usePageController());
|
||||||
|
|
||||||
|
/// The main vertically scrolling page controller with each list of memories
|
||||||
|
final memoryPageController = usePageController(initialPage: memoryIndex);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
// Memories is an immersive activity
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
return () {
|
||||||
|
// Clean up to normal edge to edge when we are done
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
toNextMemory() {
|
||||||
|
memoryPageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
curve: Curves.easeIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toPreviousMemory() {
|
||||||
|
if (currentMemoryIndex.value > 0) {
|
||||||
|
// Move to the previous memory page
|
||||||
|
memoryPageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
curve: Curves.easeIn,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the next frame to ensure the page is built
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final previousIndex = currentMemoryIndex.value - 1;
|
||||||
|
final previousMemoryController =
|
||||||
|
memoryAssetPageControllers[previousIndex];
|
||||||
|
|
||||||
|
// Ensure the controller is attached
|
||||||
|
if (previousMemoryController.hasClients) {
|
||||||
|
previousMemoryController
|
||||||
|
.jumpToPage(memories[previousIndex].assets.length - 1);
|
||||||
|
} else {
|
||||||
|
// Wait for the next frame until it is attached
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (previousMemoryController.hasClients) {
|
||||||
|
previousMemoryController
|
||||||
|
.jumpToPage(memories[previousIndex].assets.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toNextAsset(int currentAssetIndex) {
|
||||||
|
if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
|
||||||
|
// Go to the next asset
|
||||||
|
PageController controller =
|
||||||
|
memoryAssetPageControllers[currentMemoryIndex.value];
|
||||||
|
|
||||||
|
controller.nextPage(
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Go to the next memory since we are at the end of our assets
|
||||||
|
toNextMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toPreviousAsset(int currentAssetIndex) {
|
||||||
|
if (currentAssetIndex > 0) {
|
||||||
|
// Go to the previous asset
|
||||||
|
PageController controller =
|
||||||
|
memoryAssetPageControllers[currentMemoryIndex.value];
|
||||||
|
|
||||||
|
controller.previousPage(
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Go to the previous memory since we are at the end of our assets
|
||||||
|
toPreviousMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressText() {
|
||||||
|
assetProgress.value =
|
||||||
|
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads and caches the image for the asset at this [currentMemory]'s index
|
||||||
|
precacheAsset(int index) async {
|
||||||
|
// Guard index out of range
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context might be removed due to popping out of Memory Lane during Scroll handling
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
late RemoteAsset asset;
|
||||||
|
if (index < currentMemory.value.assets.length) {
|
||||||
|
// Uses the next asset in this current memory
|
||||||
|
asset = currentMemory.value.assets[index];
|
||||||
|
} else {
|
||||||
|
// Precache the first asset in the next memory if available
|
||||||
|
final currentMemoryIndex = memories.indexOf(currentMemory.value);
|
||||||
|
|
||||||
|
// Guard no memory found
|
||||||
|
if (currentMemoryIndex == -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextMemoryIndex = currentMemoryIndex + 1;
|
||||||
|
// Guard no next memory
|
||||||
|
if (nextMemoryIndex >= memories.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first asset from the next memory
|
||||||
|
asset = memories[nextMemoryIndex].assets.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precache the asset
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
await precacheImage(
|
||||||
|
getFullImageProvider(
|
||||||
|
asset,
|
||||||
|
size: Size(size.width, size.height),
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precache the next page right away if we are on the first page
|
||||||
|
if (currentAssetPage.value == 0) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 200))
|
||||||
|
.then((_) => precacheAsset(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onAssetChanged(int otherIndex) async {
|
||||||
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
currentAssetPage.value = otherIndex;
|
||||||
|
updateProgressText();
|
||||||
|
|
||||||
|
// Wait for page change animation to finish
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
// And then precache the next asset
|
||||||
|
await precacheAsset(otherIndex + 1);
|
||||||
|
|
||||||
|
final asset = currentMemory.value.assets[otherIndex];
|
||||||
|
currentAsset.value = asset;
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||||
|
// if (asset.isVideo || asset.isMotionPhoto) {
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
|
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
|
||||||
|
* page during the end of scroll is different than the current page
|
||||||
|
*/
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (ScrollNotification notification) {
|
||||||
|
// Calculate OverScroll manually using the number of pixels away from maxScrollExtent
|
||||||
|
// maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
|
||||||
|
// or sum of vertical pixels of all memories for depth = 0
|
||||||
|
if (notification is ScrollUpdateNotification) {
|
||||||
|
final isEpiloguePage =
|
||||||
|
(memoryPageController.page?.floor() ?? 0) >= memories.length;
|
||||||
|
|
||||||
|
final offset = notification.metrics.pixels;
|
||||||
|
if (isEpiloguePage &&
|
||||||
|
(offset > notification.metrics.maxScrollExtent + 150)) {
|
||||||
|
context.maybePop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
body: SafeArea(
|
||||||
|
child: PageView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(
|
||||||
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
|
),
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
controller: memoryPageController,
|
||||||
|
onPageChanged: (pageNumber) {
|
||||||
|
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
|
||||||
|
if (pageNumber < memories.length) {
|
||||||
|
currentMemoryIndex.value = pageNumber;
|
||||||
|
currentMemory.value = memories[pageNumber];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAssetPage.value = 0;
|
||||||
|
|
||||||
|
updateProgressText();
|
||||||
|
},
|
||||||
|
itemCount: memories.length + 1,
|
||||||
|
itemBuilder: (context, mIndex) {
|
||||||
|
// Build last page
|
||||||
|
if (mIndex == memories.length) {
|
||||||
|
return MemoryEpilogue(
|
||||||
|
onStartOver: () => memoryPageController.animateToPage(
|
||||||
|
0,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final yearsAgo = DateTime.now().year - memories[mIndex].data.year;
|
||||||
|
final title = 'years_ago'.t(
|
||||||
|
context: context,
|
||||||
|
args: {
|
||||||
|
'years': yearsAgo.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Build horizontal page
|
||||||
|
final assetController = memoryAssetPageControllers[mIndex];
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: 24.0,
|
||||||
|
right: 24.0,
|
||||||
|
top: 8.0,
|
||||||
|
bottom: 2.0,
|
||||||
|
),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: assetController,
|
||||||
|
builder: (context, child) {
|
||||||
|
double value = 0.0;
|
||||||
|
if (assetController.hasClients) {
|
||||||
|
// We can only access [page] if this has clients
|
||||||
|
value = assetController.page ?? 0;
|
||||||
|
}
|
||||||
|
return MemoryProgressIndicator(
|
||||||
|
ticks: memories[mIndex].assets.length,
|
||||||
|
value: (value + 1) / memories[mIndex].assets.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
PageView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(
|
||||||
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
|
),
|
||||||
|
controller: assetController,
|
||||||
|
onPageChanged: onAssetChanged,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: memories[mIndex].assets.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final asset = memories[mIndex].assets[index];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: DriftMemoryCard(
|
||||||
|
asset: asset,
|
||||||
|
title: title,
|
||||||
|
showTitle: index == 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Left side of the screen
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () {
|
||||||
|
toPreviousAsset(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Right side of the screen
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () {
|
||||||
|
toNextAsset(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: MaterialButton(
|
||||||
|
minWidth: 0,
|
||||||
|
onPressed: () {
|
||||||
|
// auto_route doesn't invoke pop scope, so
|
||||||
|
// turn off full screen mode here
|
||||||
|
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
|
||||||
|
context.maybePop();
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.edgeToEdge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
elevation: 0,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (currentAsset.value != null &&
|
||||||
|
currentAsset.value!.isVideo)
|
||||||
|
Positioned(
|
||||||
|
bottom: 24,
|
||||||
|
right: 32,
|
||||||
|
child: Icon(
|
||||||
|
Icons.videocam_outlined,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DriftMemoryBottomInfo(
|
||||||
|
memory: memories[mIndex],
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
// ignore_for_file: require_trailing_commas
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
|
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
|
||||||
|
|
||||||
|
class DriftMemoryBottomInfo extends StatelessWidget {
|
||||||
|
final DriftMemory memory;
|
||||||
|
final String title;
|
||||||
|
const DriftMemoryBottomInfo({
|
||||||
|
super.key,
|
||||||
|
required this.memory,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final df = DateFormat.yMMMMd();
|
||||||
|
final fileCreatedDate = memory.assets.first.createdAt;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
fontSize: 13.0,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
df.format(fileCreatedDate),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15.0,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MaterialButton(
|
||||||
|
minWidth: 0,
|
||||||
|
onPressed: () {
|
||||||
|
context.maybePop();
|
||||||
|
scrollToDateNotifierProvider.scrollToDate(fileCreatedDate);
|
||||||
|
},
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
elevation: 0,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.open_in_new,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
159
mobile/lib/presentation/widgets/memory/memory_card.widget.dart
Normal file
159
mobile/lib/presentation/widgets/memory/memory_card.widget.dart
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||||
|
|
||||||
|
class DriftMemoryCard extends StatelessWidget {
|
||||||
|
final RemoteAsset asset;
|
||||||
|
final String title;
|
||||||
|
final bool showTitle;
|
||||||
|
final Function()? onVideoEnded;
|
||||||
|
|
||||||
|
const DriftMemoryCard({
|
||||||
|
required this.asset,
|
||||||
|
required this.title,
|
||||||
|
required this.showTitle,
|
||||||
|
this.onVideoEnded,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
color: Colors.black,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(25.0)),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Colors.black,
|
||||||
|
width: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
SizedBox.expand(
|
||||||
|
child: _BlurredBackdrop(asset: asset),
|
||||||
|
),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Determine the fit using the aspect ratio
|
||||||
|
BoxFit fit = BoxFit.contain;
|
||||||
|
if (asset.width != null && asset.height != null) {
|
||||||
|
final aspectRatio = asset.width! / asset.height!;
|
||||||
|
final phoneAspectRatio =
|
||||||
|
constraints.maxWidth / constraints.maxHeight;
|
||||||
|
// Look for a 25% difference in either direction
|
||||||
|
if (phoneAspectRatio * .75 < aspectRatio &&
|
||||||
|
phoneAspectRatio * 1.25 > aspectRatio) {
|
||||||
|
// Cover to look nice if we have nearly the same aspect ratio
|
||||||
|
fit = BoxFit.cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.isImage) {
|
||||||
|
return Hero(
|
||||||
|
tag: 'memory-${asset.id}',
|
||||||
|
child: FullImage(
|
||||||
|
asset,
|
||||||
|
fit: fit,
|
||||||
|
size: const Size(double.infinity, double.infinity),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Hero(
|
||||||
|
tag: 'memory-${asset.id}',
|
||||||
|
// child: SizedBox(
|
||||||
|
// width: context.width,
|
||||||
|
// height: context.height,
|
||||||
|
// child: NativeVideoViewerPage(
|
||||||
|
// key: ValueKey(asset.id),
|
||||||
|
// asset: asset,
|
||||||
|
// showControls: false,
|
||||||
|
// playbackDelayFactor: 2,
|
||||||
|
// image: ImmichImage(
|
||||||
|
// asset,
|
||||||
|
// width: context.width,
|
||||||
|
// height: context.height,
|
||||||
|
// fit: BoxFit.contain,
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
// ),
|
||||||
|
child: FullImage(
|
||||||
|
asset,
|
||||||
|
fit: fit,
|
||||||
|
size: const Size(double.infinity, double.infinity),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (showTitle)
|
||||||
|
Positioned(
|
||||||
|
left: 18.0,
|
||||||
|
bottom: 18.0,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: context.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlurredBackdrop extends HookWidget {
|
||||||
|
final RemoteAsset asset;
|
||||||
|
|
||||||
|
const _BlurredBackdrop({required this.asset});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final blurhash = useDriftBlurHashRef(asset).value;
|
||||||
|
if (blurhash != null) {
|
||||||
|
// Use a nice cheap blur hash image decoration
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: MemoryImage(
|
||||||
|
blurhash,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fall back to using a more expensive image filtered
|
||||||
|
// Since the ImmichImage is already precached, we can
|
||||||
|
// safely use that as the image provider
|
||||||
|
return ImageFiltered(
|
||||||
|
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: getFullImageProvider(
|
||||||
|
asset,
|
||||||
|
size: Size(context.width, context.height),
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
115
mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
Normal file
115
mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class DriftMemoryLane extends ConsumerWidget {
|
||||||
|
final List<DriftMemory> memories;
|
||||||
|
|
||||||
|
const DriftMemoryLane({super.key, required this.memories});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxHeight: 200,
|
||||||
|
),
|
||||||
|
child: CarouselView(
|
||||||
|
itemExtent: 145.0,
|
||||||
|
shrinkExtent: 1.0,
|
||||||
|
elevation: 2,
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
overlayColor: WidgetStateProperty.all(
|
||||||
|
Colors.white.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
onTap: (index) {
|
||||||
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
|
|
||||||
|
if (memories[index].assets.isNotEmpty) {
|
||||||
|
final asset = memories[index].assets[0];
|
||||||
|
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||||
|
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.pushRoute(
|
||||||
|
DriftMemoryRoute(
|
||||||
|
memories: memories,
|
||||||
|
memoryIndex: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
children:
|
||||||
|
memories.map((memory) => DriftMemoryCard(memory: memory)).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftMemoryCard extends ConsumerWidget {
|
||||||
|
const DriftMemoryCard({
|
||||||
|
super.key,
|
||||||
|
required this.memory,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DriftMemory memory;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final yearsAgo = DateTime.now().year - memory.data.year;
|
||||||
|
final title = 'years_ago'.t(
|
||||||
|
context: context,
|
||||||
|
args: {
|
||||||
|
'years': yearsAgo.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Center(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.black.withValues(alpha: 0.2),
|
||||||
|
BlendMode.darken,
|
||||||
|
),
|
||||||
|
child: Hero(
|
||||||
|
tag: 'memory-${memory.assets[0].id}',
|
||||||
|
child: SizedBox(
|
||||||
|
width: 205,
|
||||||
|
height: 200,
|
||||||
|
child: Thumbnail(
|
||||||
|
remoteId: memory.assets[0].id,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 16,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: 114,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,8 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
final double bottomPadding;
|
final double bottomPadding;
|
||||||
|
|
||||||
|
final double? monthSegmentSnappingOffset;
|
||||||
|
|
||||||
Scrubber({
|
Scrubber({
|
||||||
super.key,
|
super.key,
|
||||||
Key? scrollThumbKey,
|
Key? scrollThumbKey,
|
||||||
@ -33,6 +35,7 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
required this.timelineHeight,
|
required this.timelineHeight,
|
||||||
this.topPadding = 0,
|
this.topPadding = 0,
|
||||||
this.bottomPadding = 0,
|
this.bottomPadding = 0,
|
||||||
|
this.monthSegmentSnappingOffset,
|
||||||
required this.child,
|
required this.child,
|
||||||
}) : assert(child.scrollDirection == Axis.vertical);
|
}) : assert(child.scrollDirection == Axis.vertical);
|
||||||
|
|
||||||
@ -296,7 +299,10 @@ class ScrubberState extends ConsumerState<Scrubber>
|
|||||||
final viewportHeight = _scrollController.position.viewportDimension;
|
final viewportHeight = _scrollController.position.viewportDimension;
|
||||||
|
|
||||||
final targetScrollOffset = layoutSegment.startOffset;
|
final targetScrollOffset = layoutSegment.startOffset;
|
||||||
final centeredOffset = targetScrollOffset - (viewportHeight / 4) + 100;
|
final centeredOffset = targetScrollOffset -
|
||||||
|
(viewportHeight / 4) +
|
||||||
|
100 +
|
||||||
|
(widget.monthSegmentSnappingOffset ?? 0.0);
|
||||||
|
|
||||||
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
|
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,10 @@ 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';
|
||||||
|
|
||||||
class Timeline extends StatelessWidget {
|
class Timeline extends StatelessWidget {
|
||||||
const Timeline({super.key});
|
const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight});
|
||||||
|
|
||||||
|
final Widget? topSliverWidget;
|
||||||
|
final double? topSliverWidgetHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -38,7 +41,10 @@ class Timeline extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const _SliverTimeline(),
|
child: _SliverTimeline(
|
||||||
|
topSliverWidget: topSliverWidget,
|
||||||
|
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -46,7 +52,10 @@ class Timeline extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SliverTimeline extends ConsumerStatefulWidget {
|
class _SliverTimeline extends ConsumerStatefulWidget {
|
||||||
const _SliverTimeline();
|
const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight});
|
||||||
|
|
||||||
|
final Widget? topSliverWidget;
|
||||||
|
final double? topSliverWidgetHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
@ -91,6 +100,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: totalAppBarHeight + 10,
|
topPadding: totalAppBarHeight + 10,
|
||||||
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
||||||
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
cacheExtent: maxHeight * 2,
|
cacheExtent: maxHeight * 2,
|
||||||
@ -100,6 +110,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
snap: false,
|
snap: false,
|
||||||
),
|
),
|
||||||
|
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||||
_SliverSegmentedList(
|
_SliverSegmentedList(
|
||||||
segments: segments,
|
segments: segments,
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
27
mobile/lib/providers/infrastructure/memory.provider.dart
Normal file
27
mobile/lib/providers/infrastructure/memory.provider.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import 'db.provider.dart';
|
||||||
|
|
||||||
|
final driftMemoryRepositoryProvider = Provider<DriftMemoryRepository>(
|
||||||
|
(ref) => DriftMemoryRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final driftMemoryServiceProvider = Provider<DriftMemoryService>(
|
||||||
|
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final driftMemoryFutureProvider =
|
||||||
|
FutureProvider.autoDispose<List<DriftMemory>>((ref) async {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final service = ref.watch(driftMemoryServiceProvider);
|
||||||
|
|
||||||
|
return service.getMemoryLane(user.id);
|
||||||
|
});
|
@ -1,7 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
|
|
||||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
|
||||||
import 'package:immich_mobile/services/memory.service.dart';
|
import 'package:immich_mobile/services/memory.service.dart';
|
||||||
|
|
||||||
final memoryFutureProvider =
|
final memoryFutureProvider =
|
||||||
@ -10,7 +8,3 @@ final memoryFutureProvider =
|
|||||||
|
|
||||||
return await service.getMemoryLane();
|
return await service.getMemoryLane();
|
||||||
});
|
});
|
||||||
|
|
||||||
final driftMemoryProvider = Provider<DriftMemoryRepository>(
|
|
||||||
(ref) => DriftMemoryRepository(ref.watch(driftProvider)),
|
|
||||||
);
|
|
||||||
|
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
@ -71,6 +72,7 @@ import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
@ -83,6 +85,7 @@ import 'package:immich_mobile/services/api.service.dart';
|
|||||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
|
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
@ -385,6 +388,11 @@ class AppRouter extends RootStackRouter {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: DriftMemoryRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
),
|
||||||
|
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
@ -618,6 +618,58 @@ class DriftAlbumsRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftMemoryPage]
|
||||||
|
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
|
||||||
|
DriftMemoryRoute({
|
||||||
|
required List<DriftMemory> memories,
|
||||||
|
required int memoryIndex,
|
||||||
|
Key? key,
|
||||||
|
List<PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
DriftMemoryRoute.name,
|
||||||
|
args: DriftMemoryRouteArgs(
|
||||||
|
memories: memories,
|
||||||
|
memoryIndex: memoryIndex,
|
||||||
|
key: key,
|
||||||
|
),
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'DriftMemoryRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final args = data.argsAs<DriftMemoryRouteArgs>();
|
||||||
|
return DriftMemoryPage(
|
||||||
|
memories: args.memories,
|
||||||
|
memoryIndex: args.memoryIndex,
|
||||||
|
key: args.key,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftMemoryRouteArgs {
|
||||||
|
const DriftMemoryRouteArgs({
|
||||||
|
required this.memories,
|
||||||
|
required this.memoryIndex,
|
||||||
|
this.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<DriftMemory> memories;
|
||||||
|
|
||||||
|
final int memoryIndex;
|
||||||
|
|
||||||
|
final Key? key;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriftMemoryRouteArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [EditImagePage]
|
/// [EditImagePage]
|
||||||
class EditImageRoute extends PageRouteInfo<EditImageRouteArgs> {
|
class EditImageRoute extends PageRouteInfo<EditImageRouteArgs> {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
import 'package:thumbhash/thumbhash.dart' as thumbhash;
|
||||||
|
|
||||||
@ -15,3 +16,15 @@ ObjectRef<Uint8List?> useBlurHashRef(Asset? asset) {
|
|||||||
|
|
||||||
return useRef(thumbhash.rgbaToBmp(rbga));
|
return useRef(thumbhash.rgbaToBmp(rbga));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
|
||||||
|
if (asset?.thumbHash == null) {
|
||||||
|
return useRef(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rbga = thumbhash.thumbHashToRGBA(
|
||||||
|
base64Decode(asset!.thumbHash!),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useRef(thumbhash.rgbaToBmp(rbga));
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user