merge main

This commit is contained in:
shenlong-tanwen
2025-09-17 16:08:34 +05:30
528 changed files with 17365 additions and 6390 deletions
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -135,7 +136,7 @@ class FeatInDevPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Features in Development'), centerTitle: true),
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
body: Column(
children: [
Flexible(
@@ -15,6 +15,7 @@ class MainTimelinePage extends ConsumerWidget {
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
showStorageIndicator: true,
);
}
}
@@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -55,7 +56,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Local Media Summary')),
appBar: AppBar(title: Text('local_media_summary'.tr())),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
@@ -78,7 +79,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("Album summary", style: ctx.textTheme.titleMedium),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
),
],
),
@@ -135,7 +136,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Remote Media Summary')),
appBar: AppBar(title: Text('remote_media_summary'.tr())),
body: Consumer(
builder: (ctx, ref, __) {
final db = ref.watch(driftProvider);
@@ -158,7 +159,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 15),
child: Text("Album summary", style: ctx.textTheme.titleMedium),
child: Text("album_summary".tr(), style: ctx.textTheme.titleMedium),
),
],
),
@@ -210,7 +210,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
activityEnabled.value = value;
await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value);
},
activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
@@ -0,0 +1,352 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/exif.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
class AssetTroubleshootPage extends ConsumerWidget {
final BaseAsset asset;
const AssetTroubleshootPage({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _AssetDetailsView(asset: asset),
),
),
);
}
}
class _AssetDetailsView extends ConsumerWidget {
final BaseAsset asset;
const _AssetDetailsView({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AssetPropertiesSection(asset: asset),
const SizedBox(height: 16),
Text(
'matching_assets'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
if (asset.checksum != null) ...[
_LocalAssetsSection(asset: asset),
const SizedBox(height: 16),
_RemoteAssetSection(asset: asset),
] else ...[
_PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_local'.tr())],
),
const SizedBox(height: 16),
_PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_checksum_remote'.tr())],
),
],
],
);
}
}
class _AssetPropertiesSection extends ConsumerStatefulWidget {
final BaseAsset asset;
const _AssetPropertiesSection({required this.asset});
@override
ConsumerState createState() => _AssetPropertiesSectionState();
}
class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection> {
List<_PropertyItem> properties = [];
@override
void initState() {
super.initState();
_buildAssetProperties(widget.asset).whenComplete(() {
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
final title = _getAssetTypeTitle(widget.asset);
return _PropertySectionCard(title: title, properties: properties);
}
Future<void> _buildAssetProperties(BaseAsset asset) async {
_addCommonProperties();
if (asset is LocalAsset) {
await _addLocalAssetProperties(asset);
} else if (asset is RemoteAsset) {
await _addRemoteAssetProperties(asset);
}
}
void _addCommonProperties() {
final asset = widget.asset;
properties.addAll([
_PropertyItem(label: 'Name', value: asset.name),
_PropertyItem(label: 'Checksum', value: asset.checksum),
_PropertyItem(label: 'Type', value: asset.type.toString()),
_PropertyItem(label: 'Created At', value: asset.createdAt.toString()),
_PropertyItem(label: 'Updated At', value: asset.updatedAt.toString()),
_PropertyItem(label: 'Width', value: asset.width?.toString()),
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(
label: 'Duration',
value: asset.durationInSeconds != null ? '${asset.durationInSeconds} seconds' : null,
),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
]);
}
Future<void> _addLocalAssetProperties(LocalAsset asset) async {
properties.insertAll(0, [
_PropertyItem(label: 'Local ID', value: asset.id),
_PropertyItem(label: 'Remote ID', value: asset.remoteId),
]);
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
}
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {
properties.insertAll(0, [
_PropertyItem(label: 'Remote ID', value: asset.id),
_PropertyItem(label: 'Local ID', value: asset.localId),
_PropertyItem(label: 'Owner ID', value: asset.ownerId),
]);
final additionalProps = <_PropertyItem>[
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId),
];
properties.insertAll(4, additionalProps);
final exif = await ref.read(assetServiceProvider).getExif(asset);
if (exif != null) {
_addExifProperties(exif);
} else {
properties.add(const _PropertyItem(label: 'EXIF', value: null));
}
}
void _addExifProperties(ExifInfo exif) {
properties.addAll([
_PropertyItem(
label: 'File Size',
value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null,
),
_PropertyItem(label: 'Description', value: exif.description),
_PropertyItem(label: 'EXIF Width', value: exif.width?.toString()),
_PropertyItem(label: 'EXIF Height', value: exif.height?.toString()),
_PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()),
_PropertyItem(label: 'Time Zone', value: exif.timeZone),
_PropertyItem(label: 'Camera Make', value: exif.make),
_PropertyItem(label: 'Camera Model', value: exif.model),
_PropertyItem(label: 'Lens', value: exif.lens),
_PropertyItem(label: 'F-Number', value: exif.f != null ? 'f/${exif.fNumber}' : null),
_PropertyItem(label: 'Focal Length', value: exif.mm != null ? '${exif.focalLength}mm' : null),
_PropertyItem(label: 'ISO', value: exif.iso?.toString()),
_PropertyItem(label: 'Exposure Time', value: exif.exposureTime.isNotEmpty ? exif.exposureTime : null),
_PropertyItem(
label: 'GPS Coordinates',
value: exif.hasCoordinates ? '${exif.latitude}, ${exif.longitude}' : null,
),
_PropertyItem(
label: 'Location',
value: [exif.city, exif.state, exif.country].where((e) => e != null && e.isNotEmpty).join(', '),
),
]);
}
String _getAssetTypeTitle(BaseAsset asset) {
if (asset is LocalAsset) return 'Local Asset';
if (asset is RemoteAsset) return 'Remote Asset';
return 'Base Asset';
}
}
class _LocalAssetsSection extends ConsumerWidget {
final BaseAsset asset;
const _LocalAssetsSection({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetService = ref.watch(assetServiceProvider);
return FutureBuilder<List<LocalAsset?>>(
future: assetService.getLocalAssetsByChecksum(asset.checksum!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'Loading...')],
);
}
if (snapshot.hasError) {
return _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())],
);
}
final localAssets = snapshot.data?.cast<LocalAsset>() ?? [];
if (asset is LocalAsset) {
localAssets.removeWhere((a) => a.id == (asset as LocalAsset).id);
if (localAssets.isEmpty) {
return const SizedBox.shrink();
}
}
if (localAssets.isEmpty) {
return _PropertySectionCard(
title: 'Local Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_local_assets_found'.tr())],
);
}
return Column(
children: [
if (localAssets.length > 1)
_PropertySectionCard(
title: 'Local Assets Summary',
properties: [_PropertyItem(label: 'Total Count', value: localAssets.length.toString())],
),
...localAssets.map((localAsset) {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: _AssetPropertiesSection(asset: localAsset),
);
}),
],
);
},
);
}
}
class _RemoteAssetSection extends ConsumerWidget {
final BaseAsset asset;
const _RemoteAssetSection({required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetService = ref.watch(assetServiceProvider);
if (asset is RemoteAsset) {
return const SizedBox.shrink();
}
return FutureBuilder<RemoteAsset?>(
future: assetService.getRemoteAssetByChecksum(asset.checksum!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'Loading...')],
);
}
if (snapshot.hasError) {
return _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Error', value: snapshot.error.toString())],
);
}
final remoteAsset = snapshot.data;
if (remoteAsset == null) {
return _PropertySectionCard(
title: 'Remote Assets',
properties: [_PropertyItem(label: 'Status', value: 'no_remote_assets_found'.tr())],
);
}
return _AssetPropertiesSection(asset: remoteAsset);
},
);
}
}
class _PropertySectionCard extends StatelessWidget {
final String title;
final List<_PropertyItem> properties;
const _PropertySectionCard({required this.title, required this.properties});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...properties,
],
),
),
);
}
}
class _PropertyItem extends StatelessWidget {
final String label;
final String? value;
const _PropertyItem({required this.label, this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)),
),
Expanded(
child: Text(
value ?? 'not_available'.tr(),
style: TextStyle(color: Theme.of(context).colorScheme.secondary),
),
),
],
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
@@ -302,7 +303,9 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}).toList();
},
error: (error, _) {
return [Center(child: Text('Error: $error'))];
return [
Center(child: Text('error_saving_image'.tr(args: [error.toString()]))),
];
},
loading: () {
return [const Center(child: CircularProgressIndicator())];
@@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -46,9 +47,9 @@ class _AlbumList extends ConsumerWidget {
),
data: (albums) {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('no_albums_yet'.tr())),
),
);
}
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@RoutePage()
class DriftPartnerDetailPage extends StatelessWidget {
@@ -68,7 +69,7 @@ class _InfoBoxState extends ConsumerState<_InfoBox> {
_inTimeline = !_inTimeline;
});
} catch (error, stack) {
debugPrint("Failed to toggle in timeline: $error $stack");
dPrint(() => "Failed to toggle in timeline: $error $stack");
ImmichToast.show(
context: context,
toastType: ToastType.error,
@@ -3,9 +3,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@@ -26,11 +25,9 @@ final driftUsersProvider = FutureProvider.autoDispose<List<UserDto>>((ref) async
id: entity.id,
name: entity.name,
email: entity.email,
isAdmin: entity.isAdmin,
updatedAt: entity.updatedAt,
isPartnerSharedBy: false,
isPartnerSharedWith: false,
avatarColor: AvatarColor.primary,
avatarColor: entity.avatarColor,
memoryEnabled: true,
inTimeline: true,
profileChangedAt: entity.profileChangedAt,
@@ -26,6 +26,7 @@ class LocalTimelinePage extends StatelessWidget {
child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
bottomSheet: const LocalAlbumBottomSheet(),
showStorageIndicator: true,
),
);
}
@@ -440,7 +440,7 @@ class DriftSearchPage extends HookConsumerWidget {
}
},
icon: const Icon(Icons.more_vert_rounded),
tooltip: 'Show text search menu',
tooltip: 'show_text_search_menu'.tr(),
);
},
menuChildren: [
@@ -633,6 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
groupBy: GroupAssetsBy.none,
appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
withScrubber: false,
),
),
),
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source;
const AdvancedInfoActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
ref.read(actionProvider.notifier).troubleshoot(source, context);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,4 +1,5 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -58,7 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
label: "like".t(context: context),
menuItem: menuItem,
),
error: (error, stack) => Text("Error: $error"),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
);
}
}
@@ -40,6 +40,7 @@ class AlbumSelector extends ConsumerStatefulWidget {
class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
bool isGrid = false;
final searchController = TextEditingController();
final menuController = MenuController();
final searchFocusNode = FocusNode();
List<RemoteAlbum> sortedAlbums = [];
List<RemoteAlbum> shownAlbums = [];
@@ -157,34 +158,45 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
await sortAlbums();
});
return MultiSliver(
children: [
_SearchBar(
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearch: onSearch,
filterMode: filter.mode,
onClearSearch: clearSearch,
),
_QuickFilterButtonRow(
filterMode: filter.mode,
onChangeFilter: changeFilter,
onSearch: onSearch,
searchController: searchController,
),
_QuickSortAndViewMode(isGrid: isGrid, onToggleViewMode: toggleViewMode, onSortChanged: changeSort),
isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
],
return PopScope(
onPopInvokedWithResult: (didPop, _) {
menuController.close();
},
child: MultiSliver(
children: [
_SearchBar(
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearch: onSearch,
filterMode: filter.mode,
onClearSearch: clearSearch,
),
_QuickFilterButtonRow(
filterMode: filter.mode,
onChangeFilter: changeFilter,
onSearch: onSearch,
searchController: searchController,
),
_QuickSortAndViewMode(
isGrid: isGrid,
onToggleViewMode: toggleViewMode,
onSortChanged: changeSort,
controller: menuController,
),
isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
: _AlbumList(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected),
],
),
);
}
}
class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged);
const _SortButton(this.onSortChanged, {this.controller});
final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller;
@override
ConsumerState<_SortButton> createState() => _SortButtonState();
@@ -220,6 +232,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
@override
Widget build(BuildContext context) {
return MenuAnchor(
controller: widget.controller,
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
@@ -449,10 +462,16 @@ class _QuickFilterButton extends StatelessWidget {
}
class _QuickSortAndViewMode extends StatelessWidget {
const _QuickSortAndViewMode({required this.isGrid, required this.onToggleViewMode, required this.onSortChanged});
const _QuickSortAndViewMode({
required this.isGrid,
required this.onToggleViewMode,
required this.onSortChanged,
this.controller,
});
final bool isGrid;
final VoidCallback onToggleViewMode;
final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged;
@override
@@ -463,7 +482,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SortButton(onSortChanged),
_SortButton(onSortChanged, controller: controller),
IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,
@@ -485,9 +504,9 @@ class _AlbumList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
),
);
}
@@ -580,9 +599,9 @@ class _AlbumGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
return const SliverToBoxAdapter(
return SliverToBoxAdapter(
child: Center(
child: Padding(padding: EdgeInsets.all(20.0), child: Text('No albums found')),
child: Padding(padding: const EdgeInsets.all(20.0), child: Text('album_search_not_found'.tr())),
),
);
}
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
@@ -30,7 +31,6 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart';
@RoutePage()
class AssetViewerPage extends StatelessWidget {
@@ -53,10 +53,9 @@ class AssetViewerPage extends StatelessWidget {
class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex;
final Platform? platform;
final int? heroOffset;
const AssetViewer({super.key, required this.initialIndex, this.platform, this.heroOffset});
const AssetViewer({super.key, required this.initialIndex, this.heroOffset});
@override
ConsumerState createState() => _AssetViewerState();
@@ -86,7 +85,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription;
late Platform platform;
late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown;
@@ -114,7 +112,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex);
platform = widget.platform ?? const LocalPlatform();
totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
@@ -638,7 +635,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: platform.isIOS
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
@@ -14,6 +15,7 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -41,6 +43,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final buttonContext = ActionButtonContext(
asset: asset,
@@ -49,6 +52,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
isTrashEnabled: isTrashEnable,
isInLockedView: isInLockedView,
currentAlbum: currentAlbum,
advancedTroubleshooting: advancedTroubleshooting,
source: ActionSource.viewer,
);
@@ -122,6 +126,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
return [fNumber, exposureTime, focalLength, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator);
}
Future<void> _editDateTime(BuildContext context, WidgetRef ref) async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
@@ -132,10 +140,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
Future<void> editDateTime() async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
return SliverList.list(
children: [
// Asset Date and Time
@@ -143,7 +147,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await editDateTime() : null,
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(),
@@ -14,9 +14,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -38,8 +39,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final previousRouteName = ref.watch(previousRouteNameProvider);
final tabRoute = ref.watch(tabProvider);
final showViewInTimelineButton =
previousRouteName != TabShellRoute.name &&
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
previousRouteName != AssetViewerRoute.name &&
previousRouteName != null;
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -43,7 +43,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -43,7 +43,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -21,6 +23,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -51,6 +54,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
@@ -88,19 +92,22 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
const AdvancedInfoActionButton(source: ActionSource.timeline),
],
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
@@ -100,7 +100,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -98,7 +98,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
final operation = cachedOperation;
if (operation != null) {
this.cachedOperation = null;
cachedOperation = null;
operation.cancel();
}
}
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
@@ -36,7 +37,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
context.pop<DateTime>(_selectedDate);
}
} catch (error) {
debugPrint('Error updating birthday: $error');
dPrint(() => 'Error updating birthday: $error');
if (!context.mounted) {
return;
@@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class DriftPersonNameEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
@@ -34,7 +35,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonNameEditFor
context.pop<String>(newName);
}
} catch (error) {
debugPrint('Error updating name: $error');
dPrint(() => 'Error updating name: $error');
if (!context.mounted) {
return;
@@ -3,14 +3,15 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
@@ -45,7 +46,7 @@ class Scrubber extends ConsumerStatefulWidget {
}
List<_Segment> _buildSegments({required List<Segment> layoutSegments, required double timelineHeight}) {
const double offsetThreshold = 20.0;
const double offsetThreshold = 40.0;
final segments = <_Segment>[];
if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) {
@@ -79,6 +80,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
int _monthCount = 0;
late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer;
@@ -105,6 +107,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
_thumbAnimation = CurvedAnimation(parent: _thumbAnimationController, curve: Curves.fastEaseInToSlowEaseOut);
_labelAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
_monthCount = getMonthCount();
_labelAnimation = CurvedAnimation(parent: _labelAnimationController, curve: Curves.fastOutSlowIn);
}
@@ -121,6 +124,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
}
}
@@ -140,6 +144,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
});
}
int getMonthCount() {
return _segments.map((e) => "${e.date.month}_${e.date.year}").toSet().length;
}
bool _onScrollNotification(ScrollNotification notification) {
if (_isDragging) {
// If the user is dragging the thumb, we don't want to update the position
@@ -169,7 +177,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragStart(DragStartDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(true);
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
ref.read(timelineStateProvider.notifier).setScrubbing(true);
}
setState(() {
_isDragging = true;
_labelAnimationController.forward();
@@ -191,13 +202,22 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
final nearestMonthSegment = _findNearestMonthSegment(dragPosition);
if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
final label = nearestMonthSegment.scrollLabel;
if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label;
}
}
if (_monthCount < kMinMonthsToEnableScrubberSnap) {
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
setState(() {
_thumbTopOffset = dragPosition;
_scrollController.jumpTo((dragPosition / _scrubberHeight) * _scrollController.position.maxScrollExtent);
});
} else if (nearestMonthSegment != null) {
_snapToSegment(nearestMonthSegment);
}
}
/// Calculate the drag position relative to the scrubber area
@@ -370,7 +390,7 @@ class _SegmentWidget extends StatelessWidget {
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
margin: const EdgeInsets.only(right: 12.0),
margin: const EdgeInsets.only(right: 36.0),
child: Material(
color: context.colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
@@ -32,17 +32,17 @@ class Timeline extends StatelessWidget {
super.key,
this.topSliverWidget,
this.topSliverWidgetHeight,
this.showStorageIndicator,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
this.groupBy,
this.withScrubber = true,
});
final Widget? topSliverWidget;
final double? topSliverWidgetHeight;
final bool? showStorageIndicator;
final bool showStorageIndicator;
final Widget? appBar;
final Widget? bottomSheet;
final bool withStack;