todo-app/lib/ui/images/image_embed.dart
Krzysztof kuhy Rudnicki 2ffae26f93 Capture feedback: haptic + checkmark on commit, motion tokens for routes and wizard bar
- Committing a captured note (New note tap with a persisted draft) fires
  HapticFeedback.lightImpact and plays a ~600 ms checkmark beside the counter;
  empty drafts and keystrokes confirm nothing.
- appRoute: one shared page route (fade + rise, AppDuration.slow) replaces all
  seven MaterialPageRoute sites; guard test keeps it that way.
- Wizard progress bar animates on AppDuration.base / AppCurve.standard.
- Everything honours disableAnimations; the haptic and the (static) checkmark
  still fire. Removes the finished dopamine-ux prompt (sound step rejected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:28:13 +01:00

111 lines
3.4 KiB
Dart

/// One attached image as it appears in a note: the picture, or a placeholder
/// saying why it is not here yet. Tapping opens it full screen.
library;
import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/images/image_viewer_screen.dart';
import 'package:todo/ui/images/local_image.dart';
/// Placeholder text for an image this device does not have yet.
const kImageNotDownloaded = 'Not downloaded yet';
/// Tooltip on the badge of an image dufs has not accepted yet.
const kImageWaitingToUpload = 'Waiting to upload';
/// Renders [ref] from the nearest [ImageStoreScope].
class ImageEmbed extends StatelessWidget {
/// Creates an embed; [fit] is `cover` for thumbnails, `contain` inline.
const ImageEmbed({required this.ref, this.fit = BoxFit.cover, super.key});
/// The image to show.
final AttachmentRef ref;
/// How the picture fills the embed's box.
final BoxFit fit;
@override
Widget build(BuildContext context) {
final store = ImageStoreScope.of(context);
final source = store.sourceFor(ref);
final picture = imageFor(source, fit: fit);
return Semantics(
label: 'Attached image',
button: source is! MissingImageSource,
child: GestureDetector(
onTap: source is MissingImageSource
? null
: () => Navigator.of(context).push<void>(
appRoute(context, (_) => ImageViewerScreen(source: source)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.passthrough,
children: [
picture,
if (store.isPending(ref))
const Positioned(
right: 4,
top: 4,
child: Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
),
),
],
),
),
),
);
}
}
/// The picture for [source], falling back to the not-downloaded placeholder
/// (also when a network load fails: the wrapper 404s until it has the file).
Widget imageFor(ImageSource source, {required BoxFit fit}) {
Widget placeholder(BuildContext context, Object error, StackTrace? stack) =>
const ImagePlaceholder();
return switch (source) {
LocalImageSource(:final path) => localImage(
path,
fit: fit,
errorBuilder: placeholder,
),
NetworkImageSource(:final url) => Image.network(
url,
fit: fit,
errorBuilder: placeholder,
),
MissingImageSource() => const ImagePlaceholder(),
};
}
/// Grey box with [kImageNotDownloaded].
class ImagePlaceholder extends StatelessWidget {
/// Creates the placeholder.
const ImagePlaceholder({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
kImageNotDownloaded,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
),
),
);
}
}