feat(mobile): memories (#2988)

* Add page view

* Nice page view

* refactor file structure

* Added card

* invalidating data

* transition

* styling

* correct styleing

* refactor

* click to navigate

* styling

* TODO

* clean up

* clean up

* pr feedback

* pr feedback

* better loading indicator
This commit is contained in:
Alex
2023-06-27 16:00:20 -05:00
committed by GitHub
parent 0e8d235148
commit 39a885a37c
17 changed files with 532 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
@@ -310,6 +311,7 @@ class HomePage extends HookConsumerWidget {
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
topWidget: const MemoryLane(),
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,

View File

@@ -0,0 +1,40 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class Memory {
final String title;
final List<Asset> assets;
Memory({
required this.title,
required this.assets,
});
Memory copyWith({
String? title,
List<Asset>? assets,
}) {
return Memory(
title: title ?? this.title,
assets: assets ?? this.assets,
);
}
@override
String toString() => 'Memory(title: $title, assets: $assets)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
final listEquals = const DeepCollectionEquality().equals;
return other is Memory &&
other.title == title &&
listEquals(other.assets, assets);
}
@override
int get hashCode => title.hashCode ^ assets.hashCode;
}

View File

@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/services/memory.service.dart';
final memoryFutureProvider = FutureProvider<List<Memory>?>((ref) async {
final service = ref.watch(memoryServiceProvider);
return await service.getMemoryLane();
});

View File

@@ -0,0 +1,50 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
return MemoryService(
ref.watch(apiServiceProvider),
);
});
class MemoryService {
final log = Logger("MemoryService");
final ApiService _apiService;
MemoryService(this._apiService);
Future<List<Memory>?> getMemoryLane() async {
try {
final now = DateTime.now();
final beginningOfDate = DateTime(now.year, now.month, now.day);
final data = await _apiService.assetApi.getMemoryLane(
beginningOfDate,
);
if (data == null) {
return null;
}
List<Memory> memories = [];
for (final MemoryLaneResponseDto(:title, :assets) in data) {
memories.add(
Memory(
title: title,
assets: assets.map((a) => Asset.remote(a)).toList(),
),
);
}
return memories.isNotEmpty ? memories : null;
} catch (error, stack) {
log.severe("Cannot get memories ${error.toString()}", error, stack);
return null;
}
}
}

View File

@@ -0,0 +1,121 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class MemoryCard extends HookConsumerWidget {
final Asset asset;
final void Function() onTap;
final void Function() onClose;
final String title;
final String? rightCornerText;
final bool showTitle;
const MemoryCard({
required this.asset,
required this.onTap,
required this.onClose,
required this.title,
required this.showTitle,
this.rightCornerText,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
buildTitle() {
return Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 24.0,
),
);
}
return Card(
color: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.0),
side: const BorderSide(
color: Colors.black,
width: 1.0,
),
),
clipBehavior: Clip.hardEdge,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
getThumbnailUrl(
asset,
),
cacheKey: getThumbnailCacheKey(
asset,
),
headers: {"Authorization": authToken},
),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 60, sigmaY: 60),
child: Container(
decoration:
BoxDecoration(color: Colors.black.withOpacity(0.25)),
),
),
),
GestureDetector(
onTap: onTap,
child: ImmichImage(
asset,
fit: BoxFit.fitWidth,
height: double.infinity,
width: double.infinity,
type: ThumbnailFormat.JPEG,
),
),
Positioned(
top: 2.0,
left: 2.0,
child: IconButton(
onPressed: onClose,
icon: const Icon(Icons.close_rounded),
color: Colors.grey[400],
),
),
Positioned(
right: 18.0,
top: 18.0,
child: Text(
rightCornerText ?? "",
style: TextStyle(
color: Colors.grey[200],
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
),
),
if (showTitle)
Positioned(
left: 18.0,
bottom: 18.0,
child: buildTitle(),
)
],
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class MemoryLane extends HookConsumerWidget {
const MemoryLane({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneFutureProvider = ref.watch(memoryFutureProvider);
final memoryLane = memoryLaneFutureProvider
.whenData(
(memories) => memories != null
? SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: memories.length,
itemBuilder: (context, index) {
final memory = memories[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0, bottom: 8),
child: GestureDetector(
onTap: () {
AutoRouter.of(context).push(
VerticalRouteView(
memories: memories,
memoryIndex: index,
),
);
},
child: Stack(
children: [
Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13.0),
),
clipBehavior: Clip.hardEdge,
child: ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.1),
BlendMode.darken,
),
child: ImmichImage(
memory.assets[0],
fit: BoxFit.cover,
width: 130,
height: 200,
useGrayBoxPlaceholder: true,
),
),
),
Positioned(
bottom: 16,
left: 16,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 114,
),
child: Text(
memory.title,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.white,
fontSize: 14,
),
),
),
),
],
),
),
);
},
),
)
: const SizedBox(),
)
.value;
return memoryLane ?? const SizedBox();
}
}

View File

@@ -0,0 +1,140 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
import 'package:intl/intl.dart';
class MemoryPage extends HookConsumerWidget {
final List<Memory> memories;
final int memoryIndex;
const MemoryPage({
required this.memories,
required this.memoryIndex,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryPageController = usePageController(initialPage: memoryIndex);
final memoryAssetPageController = usePageController();
final currentMemory = useState(memories[memoryIndex]);
final currentAssetPage = useState(0);
final assetProgress = useState(
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
);
const bgColor = Colors.black;
toNextMemory() {
memoryPageController.nextPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeIn,
);
}
toNextAsset(int currentAssetIndex) {
(currentAssetIndex + 1 < currentMemory.value.assets.length)
? memoryAssetPageController.jumpToPage(
(currentAssetIndex + 1),
)
: toNextMemory();
}
updateProgressText() {
assetProgress.value =
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
}
onMemoryChanged(int otherIndex) {
HapticFeedback.mediumImpact();
currentMemory.value = memories[otherIndex];
currentAssetPage.value = 0;
updateProgressText();
}
onAssetChanged(int otherIndex) {
HapticFeedback.selectionClick();
currentAssetPage.value = otherIndex;
updateProgressText();
}
buildBottomInfo() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentMemory.value.title,
style: TextStyle(
color: Colors.grey[400],
fontSize: 11.0,
fontWeight: FontWeight.w600,
),
),
Text(
DateFormat.yMMMMd().format(
currentMemory.value.assets[0].fileCreatedAt,
),
style: const TextStyle(
color: Colors.white,
fontSize: 14.0,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
);
}
return Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: onMemoryChanged,
itemCount: memories.length,
itemBuilder: (context, mIndex) {
// Build horizontal page
return Column(
children: [
Expanded(
child: PageView.builder(
controller: memoryAssetPageController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Container(
color: Colors.black,
child: MemoryCard(
asset: asset,
onTap: () => toNextAsset(index),
onClose: () => AutoRouter.of(context).pop(),
rightCornerText: assetProgress.value,
title: memories[mIndex].title,
showTitle: index == 0,
),
);
},
),
),
buildBottomInfo(),
],
);
},
),
),
);
}
}