nc-photos/app/lib/widget/photo_list_item.dart

418 lines
11 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/flutter_util.dart' as flutter_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/content_uri_image_provider.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/network_thumbnail.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:np_datetime/np_datetime.dart';
import 'package:to_string/to_string.dart';
part 'photo_list_item.g.dart';
@toString
abstract class PhotoListFileItem extends SelectableItem {
const PhotoListFileItem({
required this.fileIndex,
required this.file,
required this.shouldShowFavoriteBadge,
});
@override
get isTappable => true;
@override
get isSelectable => true;
@override
operator ==(Object other) =>
other is PhotoListFileItem && file.compareServerIdentity(other.file);
@override
get hashCode => file.fdPath.hashCode;
@override
String toString() => _$toString();
final int fileIndex;
final FileDescriptor file;
final bool shouldShowFavoriteBadge;
}
class PhotoListImageItem extends PhotoListFileItem {
const PhotoListImageItem({
required super.fileIndex,
required super.file,
required this.account,
required this.previewUrl,
required super.shouldShowFavoriteBadge,
});
@override
Widget buildWidget(BuildContext context) => PhotoListImage(
account: account,
previewUrl: previewUrl,
isGif: file.fdMime == "image/gif",
isFavorite: shouldShowFavoriteBadge && file.fdIsFavorite == true,
heroKey: flutter_util.getImageHeroTag(file),
);
final Account account;
final String previewUrl;
}
class PhotoListVideoItem extends PhotoListFileItem {
const PhotoListVideoItem({
required super.fileIndex,
required super.file,
required this.account,
required this.previewUrl,
required super.shouldShowFavoriteBadge,
});
@override
buildWidget(BuildContext context) => PhotoListVideo(
account: account,
previewUrl: previewUrl,
isFavorite: shouldShowFavoriteBadge && file.fdIsFavorite == true,
);
final Account account;
final String previewUrl;
}
class PhotoListDateItem extends SelectableItem {
const PhotoListDateItem({
required this.date,
this.isMonthOnly = false,
});
@override
get isTappable => false;
@override
get isSelectable => false;
@override
get staggeredTile => const StaggeredTile.extent(99, 32);
@override
buildWidget(BuildContext context) => PhotoListDate(
date: date,
isMonthOnly: isMonthOnly,
);
final Date date;
final bool isMonthOnly;
}
abstract class PhotoListLocalFileItem extends SelectableItem {
const PhotoListLocalFileItem({
required this.fileIndex,
required this.file,
});
@override
get isTappable => true;
@override
get isSelectable => true;
@override
operator ==(Object other) =>
other is PhotoListLocalFileItem && file.compareIdentity(other.file);
@override
get hashCode => file.identityHashCode;
final int fileIndex;
final LocalFile file;
}
class PhotoListLocalImageItem extends PhotoListLocalFileItem {
const PhotoListLocalImageItem({
required super.fileIndex,
required super.file,
});
@override
buildWidget(BuildContext context) {
final ImageProvider provider;
if (file is LocalUriFile) {
provider = ContentUriImage((file as LocalUriFile).uri);
} else {
throw ArgumentError("Invalid file");
}
return Padding(
padding: const EdgeInsets.all(2),
child: FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: Stack(
children: [
Container(
// arbitrary size here
constraints: BoxConstraints.tight(const Size(128, 128)),
color: Theme.of(context).listPlaceholderBackgroundColor,
child: Image(
image: ResizeImage.resizeIfNeeded(
k.photoThumbSize, null, provider),
filterQuality: FilterQuality.high,
fit: BoxFit.cover,
errorBuilder: (context, e, stackTrace) {
return Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: Theme.of(context).listPlaceholderForegroundColor,
),
);
},
),
),
Container(
// arbitrary size here
constraints: BoxConstraints.tight(const Size(128, 128)),
alignment: AlignmentDirectional.bottomEnd,
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.cloud_off,
size: 20,
color: Colors.white,
),
),
],
),
),
);
}
}
class PhotoListImage extends StatelessWidget {
const PhotoListImage({
super.key,
required this.account,
required this.previewUrl,
this.padding = const EdgeInsets.all(2),
this.isGif = false,
this.isFavorite = false,
this.heroKey,
});
@override
Widget build(BuildContext context) {
Widget buildPlaceholder() => Padding(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.image_not_supported,
color: Theme.of(context).listPlaceholderForegroundColor,
),
);
Widget child;
if (previewUrl == null) {
child = FittedBox(
child: buildPlaceholder(),
);
} else {
child = NetworkRectThumbnail(
account: account,
imageUrl: previewUrl!,
errorBuilder: (_) => buildPlaceholder(),
);
if (heroKey != null) {
child = Hero(
tag: heroKey!,
child: child,
);
}
}
return IconTheme(
data: const IconThemeData(color: Colors.white),
child: Padding(
padding: padding,
child: Stack(
children: [
Container(
color: Theme.of(context).listPlaceholderBackgroundColor,
child: child,
),
if (isGif)
Container(
alignment: AlignmentDirectional.topEnd,
padding: const EdgeInsets.symmetric(horizontal: 2),
child: const Icon(Icons.gif, size: 26),
),
if (isFavorite)
Container(
alignment: AlignmentDirectional.bottomStart,
padding: const EdgeInsets.all(6),
child: const Icon(Icons.star, size: 15),
),
],
),
),
);
}
final Account account;
final String? previewUrl;
final bool isGif;
final EdgeInsetsGeometry padding;
final bool isFavorite;
// if not null, the image will be contained by a Hero widget
final String? heroKey;
}
class PhotoListVideo extends StatelessWidget {
const PhotoListVideo({
super.key,
required this.account,
required this.previewUrl,
this.isFavorite = false,
this.onError,
});
@override
Widget build(BuildContext context) {
return IconTheme(
data: const IconThemeData(color: Colors.white),
child: Padding(
padding: const EdgeInsets.all(2),
child: Stack(
children: [
Container(
color: Theme.of(context).listPlaceholderBackgroundColor,
child: NetworkRectThumbnail(
account: account,
imageUrl: previewUrl,
errorBuilder: (context) {
onError?.call();
return Padding(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.image_not_supported,
color: Theme.of(context).listPlaceholderForegroundColor,
),
);
},
),
),
Positioned.directional(
textDirection: Directionality.of(context),
top: 6,
end: 6,
child: const Icon(Icons.play_circle_outlined, size: 17),
),
if (isFavorite)
Positioned.directional(
textDirection: Directionality.of(context),
bottom: 6,
start: 6,
child: const Icon(Icons.star, size: 15),
),
],
),
),
);
}
final Account account;
final String previewUrl;
final bool isFavorite;
final VoidCallback? onError;
}
class PhotoListLabel extends StatelessWidget {
const PhotoListLabel({
super.key,
required this.text,
});
@override
build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
text,
style: Theme.of(context).textTheme.labelLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
final String text;
}
class PhotoListLabelEdit extends PhotoListLabel {
const PhotoListLabelEdit({
super.key,
required super.text,
required this.onEditPressed,
});
@override
build(BuildContext context) {
return Stack(
children: [
// needed to expand the touch sensitive area to the whole row
Container(
color: Colors.transparent,
),
super.build(context),
PositionedDirectional(
top: 0,
bottom: 0,
end: 0,
child: IconButton(
icon: const Icon(Icons.edit),
tooltip: L10n.global().editTooltip,
onPressed: onEditPressed,
),
),
],
);
}
final VoidCallback? onEditPressed;
}
class PhotoListDate extends StatelessWidget {
const PhotoListDate({
super.key,
required this.date,
this.isMonthOnly = false,
});
@override
Widget build(BuildContext context) {
final pattern =
isMonthOnly ? DateFormat.YEAR_MONTH : DateFormat.YEAR_MONTH_DAY;
final subtitle =
DateFormat(pattern, Localizations.localeOf(context).languageCode)
.format(date.toUtcDateTime());
return Align(
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
subtitle,
style: Theme.of(context).textTheme.labelMedium,
),
),
);
}
final Date date;
final bool isMonthOnly;
}